use bytes::Bytes;
use curl::easy::{Easy2, Handler as CurlHandler, ReadError, SeekResult, WriteError};
use curl::multi::{Easy2Handle, Multi};
use http::header::{
AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, HeaderMap, HeaderValue, LOCATION,
PROXY_AUTHORIZATION,
};
use http::{Method, StatusCode};
use std::any::Any;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fmt::{self, Write as _};
use std::io::{self, Read, SeekFrom, Write};
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use crate::body::ReadSeek;
use crate::error::{Error, Result};
use crate::request::{Auth, HttpVersion, Progress, ProgressFn, Request, Upload};
use crate::response::{Response, process_header_line};
const PAUSE_ABOVE: usize = 128 * 1024;
thread_local! {
static MULTI: Multi = Multi::new();
static SPARE: RefCell<Option<Easy2<Handler>>> = const { RefCell::new(None) };
static IN_CALLBACK: Cell<bool> = const { Cell::new(false) };
static RESULTS: RefCell<HashMap<usize, std::result::Result<(), curl::Error>>> =
RefCell::new(HashMap::new());
static NEXT_TOKEN: Cell<usize> = const { Cell::new(1) };
}
struct CallbackGuard(bool);
impl CallbackGuard {
fn enter() -> Self {
CallbackGuard(IN_CALLBACK.replace(true))
}
}
impl Drop for CallbackGuard {
fn drop(&mut self) {
IN_CALLBACK.set(self.0);
}
}
#[derive(Default)]
pub(crate) struct Handler {
source: Option<Box<dyn ReadSeek>>,
status: Option<StatusCode>,
headers: HeaderMap,
headers_done: bool,
expected_len: Option<usize>,
presize: bool,
content_length: Option<u64>,
received: u64,
sent: u64,
body: Vec<u8>,
limit: Option<u64>,
too_large: bool,
pause_above: Option<usize>,
paused: bool,
header_err: Option<Error>,
read_err: Option<io::Error>,
panic: Option<Box<dyn Any + Send>>,
}
impl Handler {
fn clear(&mut self) {
let mut body = std::mem::take(&mut self.body);
body.clear();
*self = Handler {
body,
..Handler::default()
};
}
}
impl CurlHandler for Handler {
fn write(&mut self, data: &[u8]) -> std::result::Result<usize, WriteError> {
if self
.pause_above
.is_some_and(|limit| self.body.len() >= limit)
{
self.paused = true;
return Err(WriteError::Pause);
}
if let Some(limit) = self.limit
&& self.received + data.len() as u64 > limit
{
self.too_large = true;
return Ok(0);
}
if self.presize
&& self.body.is_empty()
&& let Some(len) = self.expected_len.take()
{
let _ = self.body.try_reserve_exact(len);
}
self.body.extend_from_slice(data);
self.received += data.len() as u64;
Ok(data.len())
}
fn read(&mut self, buf: &mut [u8]) -> std::result::Result<usize, ReadError> {
let Some(source) = &mut self.source else {
return Ok(0);
};
let result = catch_unwind(AssertUnwindSafe(|| {
loop {
match source.read(buf) {
Ok(n) => return Ok(n),
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
}));
match result {
Ok(Ok(n)) => {
self.sent += n as u64;
Ok(n)
}
Ok(Err(e)) => {
self.read_err = Some(e);
Err(ReadError::Abort)
}
Err(panic) => {
self.panic = Some(panic);
Err(ReadError::Abort)
}
}
}
fn seek(&mut self, whence: SeekFrom) -> SeekResult {
let Some(source) = &mut self.source else {
return SeekResult::CantSeek;
};
match catch_unwind(AssertUnwindSafe(|| source.seek(whence))) {
Ok(Ok(_)) => SeekResult::Ok,
Ok(Err(_)) => SeekResult::CantSeek,
Err(panic) => {
self.panic = Some(panic);
SeekResult::Fail
}
}
}
fn header(&mut self, line: &[u8]) -> bool {
let mut status = self.status;
if let Err(e) = process_header_line(line, &mut self.headers, &mut status) {
self.header_err = Some(e);
return false;
}
self.status = status;
if line == b"\r\n" || line == b"\n" {
self.headers_done = status.is_some_and(|s| !s.is_informational());
self.expected_len = self
.headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok()?.trim().parse().ok());
self.content_length = self.expected_len.map(|n| n as u64);
}
true
}
#[cfg(feature = "log")]
fn debug(&mut self, kind: curl::easy::InfoType, data: &[u8]) {
use curl::easy::InfoType;
let prefix = match kind {
InfoType::Text => "* ",
InfoType::HeaderIn => "< ",
InfoType::HeaderOut => "> ",
_ => return,
};
for line in String::from_utf8_lossy(data).lines() {
log::trace!(target: "curlite", "{prefix}{line}");
}
}
}
enum MultiRef {
Shared,
Private(Multi),
}
impl MultiRef {
fn with<R>(&self, f: impl FnOnce(&Multi) -> R) -> R {
match self {
MultiRef::Shared => MULTI.with(f),
MultiRef::Private(multi) => f(multi),
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum Mode {
Memory,
Stream,
}
pub(crate) struct Transfer {
handle: Option<Easy2Handle<Handler>>,
multi: MultiRef,
token: usize,
done: bool,
url: String,
progress: Option<ProgressFn>,
upload_total: Option<u64>,
reported: (u64, u64),
}
impl fmt::Debug for Transfer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Transfer")
.field("done", &self.done)
.finish_non_exhaustive()
}
}
pub(crate) fn start(
mut request: Request,
mut upload: Option<Upload>,
mode: Mode,
) -> Result<Transfer> {
let mut multi = if IN_CALLBACK.get() {
MultiRef::Private(Multi::new())
} else {
MultiRef::Shared
};
let mut easy = SPARE
.with(|spare| spare.borrow_mut().take())
.unwrap_or_else(|| Easy2::new(Handler::default()));
let mut progress = request.progress.take();
let mut hops = 0;
loop {
easy.reset();
let handler = easy.get_mut();
handler.clear();
handler.presize = matches!(mode, Mode::Memory);
handler.pause_above = matches!(mode, Mode::Stream).then_some(PAUSE_ABOVE);
handler.source = upload.as_mut().and_then(|upload| upload.source.take());
if let Err(e) = configure(&mut easy, &request, upload.as_ref()) {
recycle(easy);
return Err(e);
}
let token = NEXT_TOKEN.replace(NEXT_TOKEN.get().wrapping_add(1));
let mut handle = multi.with(|m| m.add2(easy))?;
handle.set_token(token)?;
let mut transfer = Transfer {
handle: Some(handle),
multi,
token,
done: false,
url: request.url.clone(),
progress: progress.take(),
upload_total: upload.as_ref().map(|u| u.len),
reported: (0, 0),
};
transfer.drive(|h| h.headers_done)?;
let handler = transfer.handler();
let status = handler
.status
.ok_or_else(|| Error::HeaderParse("transfer ended without a status line".to_owned()))?;
if !request.follow_redirects || !is_followed(status, handler.headers.contains_key(LOCATION))
{
if matches!(mode, Mode::Memory)
&& let Some(limit) = request.max_body_size
{
let no_body =
request.method == Method::HEAD || matches!(status.as_u16(), 204 | 304);
if !no_body
&& (handler.content_length.is_some_and(|len| len > limit)
|| handler.received > limit)
{
return Err(Error::BodyTooLarge(limit));
}
transfer.handler_mut().limit = Some(limit);
}
return Ok(transfer);
}
while !transfer.done {
transfer.drain(|_| Ok::<(), Error>(()))?;
transfer.step(|h| !h.body.is_empty())?;
}
let next;
progress = transfer.progress.take();
(easy, multi, next) = transfer.detach()?;
let Some(next) = next else {
recycle(easy);
return Err(Error::HeaderParse(
"redirect without a resolvable Location".to_owned(),
));
};
if let Some(upload) = &mut upload {
upload.source = easy.get_mut().source.take();
}
match status.as_u16() {
303 if request.method != Method::GET && request.method != Method::HEAD => {
drop_body(&mut request, &mut upload);
}
301 | 302 if request.method == Method::POST => drop_body(&mut request, &mut upload),
_ => {
if let Some(source) = upload.as_mut().and_then(|u| u.source.as_mut()) {
source.seek(SeekFrom::Start(0))?;
}
}
}
if hops == request.max_redirects {
recycle(easy);
return Err(Error::TooManyRedirects(request.max_redirects));
}
hops += 1;
if !same_origin(&request.url, &next) {
request.headers.remove(AUTHORIZATION);
request.headers.remove(COOKIE);
request.headers.remove(PROXY_AUTHORIZATION);
request.auth = None;
}
request.url = next;
}
}
fn is_followed(status: StatusCode, has_location: bool) -> bool {
matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) && has_location
}
fn drop_body(request: &mut Request, upload: &mut Option<Upload>) {
request.method = Method::GET;
request.headers.remove(CONTENT_TYPE);
*upload = None;
}
fn origin(url: &str) -> String {
let url = if url.contains("://") {
url.to_owned()
} else {
format!("http://{url}")
};
let url = url.as_str();
let after_scheme = url.find("://").map_or(0, |i| i + 3);
let end = url[after_scheme..]
.find(['/', '?', '#'])
.map_or(url.len(), |i| after_scheme + i);
let authority = &url[after_scheme..end];
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
format!("{}{host}", &url[..after_scheme]).to_ascii_lowercase()
}
fn same_origin(a: &str, b: &str) -> bool {
origin(a) == origin(b)
}
fn configure(easy: &mut Easy2<Handler>, request: &Request, upload: Option<&Upload>) -> Result<()> {
if let Some(scheme) = scheme(&request.url)
&& !scheme.eq_ignore_ascii_case("http")
&& !scheme.eq_ignore_ascii_case("https")
{
return Err(Error::UnsupportedScheme(request.url.clone()));
}
easy.url(&request.url)?;
easy.timeout(request.timeout)?;
easy.connect_timeout(request.connect_timeout)?;
if !request.stall_timeout.is_zero() {
let stall = &request.stall_timeout;
let seconds = stall.as_secs() + u64::from(stall.subsec_nanos() > 0);
easy.low_speed_limit(1)?;
easy.low_speed_time(Duration::from_secs(seconds))?;
}
easy.follow_location(false)?;
if let Some(version) = request.http_version {
easy.http_version(match version {
HttpVersion::Http11 => curl::easy::HttpVersion::V11,
HttpVersion::Http2 => curl::easy::HttpVersion::V2TLS,
HttpVersion::Http2PriorKnowledge => curl::easy::HttpVersion::V2PriorKnowledge,
HttpVersion::Http3 => curl::easy::HttpVersion::V3,
})?;
}
if let Some(proxy) = &request.proxy {
easy.proxy(proxy)?;
}
if let Some(path) = &request.unix_socket {
easy.unix_socket_path(Some(path))?;
}
let https = request
.url
.get(..8)
.is_some_and(|s| s.eq_ignore_ascii_case("https://"));
if let Some(path) = &request.ca_bundle
&& https
{
easy.cainfo(path)?;
}
if let Some((cert, key)) = &request.client_cert
&& https
{
easy.ssl_cert(cert)?;
easy.ssl_key(key)?;
}
if !request.verify_tls {
easy.ssl_verify_peer(false)?;
easy.ssl_verify_host(false)?;
} else if request.ca_bundle.is_none()
&& https
&& let Some((one_cert, dir)) = lazy_ca_store()
{
easy.cainfo(one_cert)?;
easy.capath(dir)?;
} else if request.ca_bundle.is_none() && https && native_ca_needed() {
easy.ssl_options(curl::easy::SslOpt::new().native_ca(true))?;
}
if request.compression {
easy.accept_encoding("")?;
}
if let Some(auth) = &request.auth
&& let Some((user, password, methods)) = libcurl_auth(auth)
{
easy.username(user)?;
easy.password(password)?;
easy.http_auth(&methods)?;
}
if let Some(auth) = &request.proxy_auth
&& let Some((user, password, methods)) = libcurl_auth(auth)
{
easy.proxy_username(user)?;
easy.proxy_password(password)?;
easy.proxy_auth(&methods)?;
}
if request.netrc {
easy.netrc(curl::easy::NetRc::Optional)?;
}
#[cfg(feature = "log")]
if log::log_enabled!(target: "curlite", log::Level::Trace) {
easy.verbose(true)?;
}
if request.method != Method::POST {
easy.custom_request(request.method.as_str())?;
}
if request.method == Method::HEAD {
easy.nobody(true)?;
}
match upload {
Some(upload) if upload.len > 0 => {
easy.post(true)?;
easy.post_field_size(upload.len)?;
}
Some(_) => easy.post_fields_copy(b"")?,
None => {
if matches!(request.method, Method::POST | Method::PUT | Method::PATCH) {
easy.post_fields_copy(b"")?;
}
}
}
if let Some(Auth::Bearer(_)) = &request.proxy_auth {
return Err(Error::UnsupportedScheme(
"Bearer is not a proxy authentication scheme".to_owned(),
));
}
let mut header_list = curl::easy::List::new();
if let Some(Auth::Bearer(token)) = &request.auth
&& !request.headers.contains_key(AUTHORIZATION)
{
let value = HeaderValue::from_str(&format!("Bearer {token}"))?;
header_list.append(&format!("Authorization: {}", value.to_str()?))?;
}
let mut line = String::new();
for (name, value) in &request.headers {
let value = value.to_str()?.trim();
let separator = if value.is_empty() { ";" } else { ": " };
line.clear();
let _ = write!(line, "{}{separator}{value}", name.as_str());
header_list.append(&line)?;
}
easy.http_headers(header_list)?;
Ok(())
}
fn scheme(url: &str) -> Option<&str> {
let (candidate, rest) = url.split_once(':')?;
let name = candidate.as_bytes().first()?.is_ascii_alphabetic()
&& candidate
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"+-.".contains(&b));
let port = rest.split(['/', '?', '#']).next().unwrap_or("");
let is_port = !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit());
(name && !is_port).then_some(candidate)
}
fn libcurl_auth(auth: &Auth) -> Option<(&str, &str, curl::easy::Auth)> {
let mut methods = curl::easy::Auth::new();
match auth {
Auth::Basic { .. } => methods.basic(true),
Auth::Digest { .. } => methods.digest(true),
Auth::Ntlm { .. } => methods.ntlm(true),
Auth::Negotiate => methods.gssnegotiate(true),
Auth::Bearer(_) => return None,
};
let (user, password) = match auth {
Auth::Basic { user, password }
| Auth::Digest { user, password }
| Auth::Ntlm { user, password } => (user.as_str(), password.as_str()),
_ => ("", ""),
};
Some((user, password, methods))
}
fn lazy_ca_store() -> Option<&'static (PathBuf, PathBuf)> {
static DECISION: OnceLock<Option<(PathBuf, PathBuf)>> = OnceLock::new();
DECISION
.get_or_init(|| {
if !cfg!(unix) {
return None;
}
let ssl = curl::Version::get().ssl_version()?.to_owned();
if !ssl.starts_with("OpenSSL") {
return None;
}
["/etc/ssl/certs", "/usr/lib/ssl/certs"]
.iter()
.map(Path::new)
.find_map(|dir| Some((first_hash_link(dir)?, dir.to_path_buf())))
})
.as_ref()
}
fn native_ca_needed() -> bool {
static DECISION: OnceLock<bool> = OnceLock::new();
*DECISION.get_or_init(|| {
let version = curl::Version::get();
version.ssl_version().is_some_and(|s| s.starts_with("rustls"))
&& version.cainfo().is_none()
&& version.capath().is_none()
})
}
fn first_hash_link(dir: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
entries.take(200).flatten().find_map(|entry| {
let name = entry.file_name();
let text = name.to_string_lossy();
(text.len() == 10
&& text.ends_with(".0")
&& text[..8].bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| dir.join(&name))
})
}
fn recycle(mut easy: Easy2<Handler>) {
easy.reset();
easy.get_mut().clear();
let _ = SPARE.try_with(|spare| {
let mut spare = spare.borrow_mut();
if spare.is_none() {
*spare = Some(easy);
}
});
}
impl Transfer {
fn handle(&self) -> &Easy2Handle<Handler> {
self.handle.as_ref().expect("transfer is attached")
}
pub(crate) fn handler(&self) -> &Handler {
self.handle().get_ref()
}
fn handler_mut(&mut self) -> &mut Handler {
self.handle
.as_mut()
.expect("transfer is attached")
.get_mut()
}
pub(crate) fn done(&self) -> bool {
self.done
}
pub(crate) fn drive(&mut self, until: impl Fn(&Handler) -> bool) -> Result<()> {
while !self.done && !until(self.handler()) {
self.step(&until)?;
}
Ok(())
}
pub(crate) fn step(&mut self, ready: impl Fn(&Handler) -> bool) -> Result<()> {
let result = self.step_inner(&ready);
if result.is_err() {
self.done = true;
}
self.report_progress();
result
}
fn report_progress(&mut self) {
let (Some(progress), Some(handle)) = (&mut self.progress, &self.handle) else {
return;
};
let handler = handle.get_ref();
let counts = (handler.received, handler.sent);
if counts == self.reported && !self.done {
return;
}
self.reported = counts;
progress.0(Progress {
downloaded: counts.0,
download_total: handler.content_length,
uploaded: counts.1,
upload_total: self.upload_total,
});
}
fn step_inner(&mut self, ready: &dyn Fn(&Handler) -> bool) -> Result<()> {
if self.handler().paused {
self.handle().unpause_write()?;
self.handler_mut().paused = false;
}
self.multi.with(|m| {
let _guard = CallbackGuard::enter();
m.perform()
})?;
let handler = self.handler_mut();
if let Some(panic) = handler.panic.take() {
resume_unwind(panic);
}
if let Some(e) = handler.header_err.take() {
return Err(e);
}
if handler.too_large {
return Err(Error::BodyTooLarge(handler.limit.unwrap_or(0)));
}
if let Some(e) = handler.read_err.take() {
return Err(e.into());
}
self.multi.with(|m| {
m.messages(|message| {
if let (Ok(token), Some(result)) = (message.token(), message.result()) {
RESULTS.with_borrow_mut(|results| results.insert(token, result));
}
});
});
if let Some(result) = RESULTS.with_borrow_mut(|results| results.remove(&self.token)) {
self.done = true;
return result.map_err(Error::from);
}
if !self.handler().paused && !ready(self.handler()) {
self.multi
.with(|m| m.poll(&mut [], Duration::from_millis(500)))?;
}
Ok(())
}
pub(crate) fn drain<E>(
&mut self,
consume: impl FnOnce(&[u8]) -> std::result::Result<(), E>,
) -> std::result::Result<(), E> {
let body = &mut self.handler_mut().body;
if body.is_empty() {
return Ok(());
}
let result = consume(body);
body.clear();
result
}
pub(crate) fn into_response(mut self) -> Result<Response> {
let (mut easy, _, _) = self.detach()?;
let handler = easy.get_mut();
let status = handler
.status
.ok_or_else(|| Error::HeaderParse("transfer ended without a status line".to_owned()))?;
let headers = std::mem::take(&mut handler.headers);
let mut body = std::mem::take(&mut handler.body);
body.shrink_to_fit();
recycle(easy);
Ok(Response {
url: std::mem::take(&mut self.url),
status,
headers,
body: Bytes::from(body),
})
}
fn detach(&mut self) -> Result<(Easy2<Handler>, MultiRef, Option<String>)> {
let handle = self.handle.take().expect("transfer is attached");
let next = handle.redirect_url()?.map(str::to_owned);
let easy = self.multi.with(|m| m.remove2(handle))?;
let multi = std::mem::replace(&mut self.multi, MultiRef::Shared);
RESULTS.with_borrow_mut(|results| results.remove(&self.token));
Ok((easy, multi, next))
}
}
impl Drop for Transfer {
fn drop(&mut self) {
let _ = RESULTS.try_with(|results| results.borrow_mut().remove(&self.token));
let Some(handle) = self.handle.take() else {
return;
};
if IN_CALLBACK.get() {
std::mem::forget(handle);
return;
}
let removed = match &self.multi {
MultiRef::Shared => MULTI.try_with(|m| m.remove2(handle)).ok(),
MultiRef::Private(m) => Some(m.remove2(handle)),
};
if let Some(Ok(easy)) = removed {
recycle(easy);
}
}
}
pub(crate) fn send(request: Request, upload: Option<Upload>) -> Result<Response> {
let mut transfer = start(request, upload, Mode::Memory)?;
transfer.drive(|_| false)?;
transfer.into_response()
}
pub(crate) fn send_to(
request: Request,
upload: Option<Upload>,
writer: &mut dyn Write,
) -> Result<Response> {
let mut transfer = start(request, upload, Mode::Stream)?;
loop {
transfer.drain(|chunk| writer.write_all(chunk))?;
if transfer.done() {
return transfer.into_response();
}
transfer.step(|h| !h.body.is_empty())?;
}
}
#[derive(Debug)]
pub struct ResponseReader {
transfer: Transfer,
url: String,
status: StatusCode,
headers: HeaderMap,
pos: usize,
}
impl ResponseReader {
pub(crate) fn new(request: Request, upload: Option<Upload>) -> Result<Self> {
let mut transfer = start(request, upload, Mode::Stream)?;
let handler = transfer.handler_mut();
let status = handler
.status
.ok_or_else(|| Error::HeaderParse("transfer ended without a status line".to_owned()))?;
let headers = std::mem::take(&mut handler.headers);
let url = transfer.url.clone();
Ok(ResponseReader {
transfer,
url,
status,
headers,
pos: 0,
})
}
pub fn url(&self) -> &str {
&self.url
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn content_type(&self) -> Result<Option<mime::Mime>> {
crate::response::parse_content_type(&self.headers)
}
pub fn into_response(mut self) -> Result<Response> {
let mut body = Vec::new();
self.read_to_end(&mut body)?;
Ok(Response {
url: std::mem::take(&mut self.url),
status: self.status,
headers: std::mem::take(&mut self.headers),
body: Bytes::from(body),
})
}
}
impl Read for ResponseReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
loop {
let handler = self.transfer.handler_mut();
if self.pos < handler.body.len() {
let n = (handler.body.len() - self.pos).min(buf.len());
buf[..n].copy_from_slice(&handler.body[self.pos..self.pos + n]);
self.pos += n;
if self.pos == handler.body.len() {
handler.body.clear();
self.pos = 0;
}
return Ok(n);
}
if self.transfer.done() {
return Ok(0);
}
self.transfer
.step(|h| !h.body.is_empty())
.map_err(io::Error::other)?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_link_detection() {
let dir = std::env::temp_dir().join(format!("curlite-hash-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(first_hash_link(&dir), None);
std::fs::write(dir.join("ca-certificates.crt"), "").unwrap();
std::fs::write(dir.join("deadbeef.pem"), "").unwrap();
assert_eq!(first_hash_link(&dir), None);
std::fs::write(dir.join("5ed36f99.0"), "").unwrap();
assert_eq!(first_hash_link(&dir), Some(dir.join("5ed36f99.0")));
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(first_hash_link(&dir), None);
}
#[test]
fn schemes() {
assert_eq!(scheme("http://a"), Some("http"));
assert_eq!(scheme("HTTPS://a"), Some("HTTPS"));
assert_eq!(scheme("file:/etc/passwd"), Some("file"));
assert_eq!(scheme("dict:x"), Some("dict"));
assert_eq!(scheme("gopher://h"), Some("gopher"));
assert_eq!(scheme("localhost:8080/x"), None);
assert_eq!(scheme("localhost/x?a=b:c"), None);
assert_eq!(scheme("example.com"), None);
assert_eq!(scheme(""), None);
}
#[test]
fn origins() {
assert!(same_origin("https://A.com/x?y", "https://a.com/z"));
assert!(same_origin("http://u:p@a.com/x", "http://a.com"));
assert!(!same_origin("https://a.com", "http://a.com"));
assert!(!same_origin("https://a.com", "https://a.com:8443"));
assert!(!same_origin("https://a.com", "https://b.com"));
assert!(same_origin("a.com/x", "http://A.com/y"));
assert_eq!(origin("https://a.com"), "https://a.com");
assert_eq!(origin(""), "http://");
}
}