Files
tauri-plugins-workspace/plugins/http/src/commands.rs
T
Christopher PruijsenandLucas Nogueira 0e17675653 fix(http): decode header values as ISO-8859-1 (#3586)
* fix(http): decode response header values as ISO-8859-1

Response header values were decoded with String::from_utf8 before being
sent over IPC. Raw UTF-8 bytes produced JS strings with code points
above 255, which new Headers() rejects as invalid ByteStrings
("Type error" on WebKit), and bytes that were not valid UTF-8 failed
the fetch outright. Decode as ISO-8859-1 instead so every byte maps to a
char 0-255, matching what browsers expose for the same response.

Fixes #2898

* fix(http): add tests for ISO-8859-1 response header decoding

Cover the #2898 Content-Disposition case and non-UTF-8 header bytes on
the same conversion fetch_send uses, so a revert to UTF-8 fails the suite.

* request headers, e2e test

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-13 14:43:50 -03:00

797 lines
26 KiB
Rust

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration};
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
use reqwest::{NoProxy, redirect::Policy};
use serde::{Deserialize, Serialize};
use tauri::{
Manager, ResourceId, ResourceTable, Runtime, State, Webview,
async_runtime::Mutex,
command,
ipc::{CommandScope, GlobalScope},
};
use tokio::sync::oneshot::{Receiver, Sender, channel};
use crate::{
Error, Http, Result,
scope::{Entry, Scope},
};
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
/// Matches the default [`reqwest`] redirect policy, used when the frontend does not
/// configure `maxRedirections`.
const DEFAULT_MAX_REDIRECTIONS: usize = 10;
struct ReqwestResponse(reqwest::Response);
impl tauri::Resource for ReqwestResponse {}
type CancelableResponseResult = Result<reqwest::Response>;
type CancelableResponseFuture =
Pin<Box<dyn Future<Output = CancelableResponseResult> + Send + Sync>>;
struct FetchRequest {
fut: Mutex<CancelableResponseFuture>,
abort_tx_rid: ResourceId,
abort_rx_rid: ResourceId,
}
impl tauri::Resource for FetchRequest {}
struct AbortSender(Sender<()>);
impl tauri::Resource for AbortRecveiver {}
impl AbortSender {
fn abort(self) {
let _ = self.0.send(());
}
}
struct AbortRecveiver(Receiver<()>);
impl tauri::Resource for AbortSender {}
trait AddRequest {
fn add_request(&mut self, fut: CancelableResponseFuture) -> ResourceId;
}
impl AddRequest for ResourceTable {
fn add_request(&mut self, fut: CancelableResponseFuture) -> ResourceId {
let (tx, rx) = channel::<()>();
let (tx, rx) = (AbortSender(tx), AbortRecveiver(rx));
let req = FetchRequest {
fut: Mutex::new(fut),
abort_tx_rid: self.add(tx),
abort_rx_rid: self.add(rx),
};
self.add(req)
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchResponse {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
url: String,
rid: ResourceId,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] //feature flags shoudln't affect api
pub struct DangerousSettings {
accept_invalid_certs: bool,
accept_invalid_hostnames: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientConfig {
method: String,
url: url::Url,
headers: Vec<(String, String)>,
data: Option<Vec<u8>>,
connect_timeout: Option<u64>,
max_redirections: Option<usize>,
proxy: Option<Proxy>,
danger: Option<DangerousSettings>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Proxy {
all: Option<UrlOrConfig>,
http: Option<UrlOrConfig>,
https: Option<UrlOrConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum UrlOrConfig {
Url(String),
Config(ProxyConfig),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyConfig {
url: String,
basic_auth: Option<BasicAuth>,
no_proxy: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct BasicAuth {
username: String,
password: String,
}
#[inline]
fn proxy_creator(
url_or_config: UrlOrConfig,
proxy_fn: fn(String) -> reqwest::Result<reqwest::Proxy>,
) -> reqwest::Result<reqwest::Proxy> {
match url_or_config {
UrlOrConfig::Url(url) => Ok(proxy_fn(url)?),
UrlOrConfig::Config(ProxyConfig {
url,
basic_auth,
no_proxy,
}) => {
let mut proxy = proxy_fn(url)?;
if let Some(basic_auth) = basic_auth {
proxy = proxy.basic_auth(&basic_auth.username, &basic_auth.password);
}
if let Some(no_proxy) = no_proxy {
proxy = proxy.no_proxy(NoProxy::from_string(&no_proxy));
}
Ok(proxy)
}
}
}
fn attach_proxy(
proxy: Proxy,
mut builder: reqwest::ClientBuilder,
) -> crate::Result<reqwest::ClientBuilder> {
let Proxy { all, http, https } = proxy;
if let Some(all) = all {
let proxy = proxy_creator(all, reqwest::Proxy::all)?;
builder = builder.proxy(proxy);
}
if let Some(http) = http {
let proxy = proxy_creator(http, reqwest::Proxy::http)?;
builder = builder.proxy(proxy);
}
if let Some(https) = https {
let proxy = proxy_creator(https, reqwest::Proxy::https)?;
builder = builder.proxy(proxy);
}
Ok(builder)
}
/// Builds the redirect policy for the request.
///
/// When `scope` is [`Some`], **every** hop is checked against it. Validating only the URL the
/// frontend asked for is not enough: a server on an allowed origin can answer with a redirect to
/// any other origin (an open redirect, or a server the attacker controls), and following it would
/// give the webview access to a URL the scope denies.
///
/// That check is opt-in through the `scopeRedirects` configuration because it breaks applications
/// that rely on being redirected outside of their scope.
// TODO(v3): always check the scope and take it by value instead of `Option`
fn redirect_policy(scope: Option<Scope>, max_redirections: Option<usize>) -> Policy {
if max_redirections == Some(0) {
return Policy::none();
}
let max_redirections = max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS);
let Some(scope) = scope else {
return Policy::limited(max_redirections);
};
Policy::custom(move |attempt| {
// the first URL in `previous` is the initial request, so it must be excluded
if attempt.previous().len() > max_redirections {
attempt.error("too many redirects")
} else if scope.is_allowed(attempt.url()) {
attempt.follow()
} else {
let url = attempt.url().clone();
attempt.error(Error::UrlNotAllowed(url))
}
})
}
/// [`reqwest`] wraps the error returned by the redirect policy, and its `Display` impl does not
/// include the source, so we unwrap our own scope error to keep the reason visible to the frontend.
fn map_request_error(error: reqwest::Error) -> Error {
if error.is_redirect() {
let mut source = std::error::Error::source(&error);
while let Some(err) = source {
if let Some(Error::UrlNotAllowed(url)) = err.downcast_ref::<Error>() {
return Error::UrlNotAllowed(url.clone());
}
source = err.source();
}
}
Error::Network(error)
}
#[command]
pub async fn fetch<R: Runtime>(
webview: Webview<R>,
state: State<'_, Http>,
client_config: ClientConfig,
command_scope: CommandScope<Entry>,
global_scope: GlobalScope<Entry>,
) -> crate::Result<ResourceId> {
let ClientConfig {
method,
url,
headers: headers_raw,
data,
connect_timeout,
max_redirections,
proxy,
danger,
} = client_config;
let scheme = url.scheme();
let method = Method::from_bytes(method.as_bytes())?;
let mut headers = HeaderMap::new();
for (h, v) in headers_raw {
let name = HeaderName::from_str(&h)?;
#[cfg(not(feature = "unsafe-headers"))]
if is_unsafe_header(&name) {
#[cfg(debug_assertions)]
{
eprintln!(
"[\x1b[33mWARNING\x1b[0m] Skipping {name} header as it is a forbidden header per fetch spec https://fetch.spec.whatwg.org/#terminology-headers"
);
eprintln!(
"[\x1b[33mWARNING\x1b[0m] if keeping the header is a desired behavior, you can enable `unsafe-headers` feature flag in your Cargo.toml"
);
}
continue;
}
headers.append(name, byte_string_to_header_value(&v)?);
}
match scheme {
"http" | "https" => {
let scope = Scope::new(
command_scope
.allows()
.iter()
.chain(global_scope.allows())
.cloned()
.collect(),
command_scope
.denies()
.iter()
.chain(global_scope.denies())
.cloned()
.collect(),
);
if !scope.is_allowed(&url) {
return Err(Error::UrlNotAllowed(url));
}
let mut builder = reqwest::ClientBuilder::new();
if let Some(danger_config) = danger {
#[cfg(not(feature = "dangerous-settings"))]
{
#[cfg(debug_assertions)]
{
eprintln!(
"[\x1b[33mWARNING\x1b[0m] using dangerous settings requires `dangerous-settings` feature flag in your Cargo.toml"
);
}
let _ = danger_config;
return Err(Error::DangerousSettings);
}
#[cfg(feature = "dangerous-settings")]
{
builder = builder
.danger_accept_invalid_certs(danger_config.accept_invalid_certs)
.danger_accept_invalid_hostnames(danger_config.accept_invalid_hostnames)
}
}
if let Some(timeout) = connect_timeout {
builder = builder.connect_timeout(Duration::from_millis(timeout));
}
let scope = state.config.scope_redirects.then_some(scope);
builder = builder.redirect(redirect_policy(scope, max_redirections));
if let Some(proxy_config) = proxy {
builder = attach_proxy(proxy_config, builder)?;
}
#[cfg(feature = "cookies")]
{
builder = builder.cookie_provider(state.cookies_jar.clone());
}
let mut request = builder.build()?.request(method.clone(), url);
// POST and PUT requests should always have a 0 length content-length,
// if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
if data.is_none() && matches!(method, Method::POST | Method::PUT) {
headers.append(header::CONTENT_LENGTH, HeaderValue::from_str("0")?);
}
if headers.contains_key(header::RANGE) {
// https://fetch.spec.whatwg.org/#http-network-or-cache-fetch step 18
// If httpRequest's header list contains `Range`, then append (`Accept-Encoding`, `identity`)
headers.append(header::ACCEPT_ENCODING, HeaderValue::from_str("identity")?);
}
if !headers.contains_key(header::USER_AGENT) {
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
}
// ensure we have an Origin header set
if (cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN))
&& let Ok(url) = webview.url()
{
// The url crate returns OpaqueOrigin for tauri://localhost which serializes to "null"
let origin = if url.scheme() == "tauri" {
"tauri://localhost".to_string()
} else {
url.origin().ascii_serialization()
};
headers.append(header::ORIGIN, HeaderValue::from_str(&origin)?);
}
// In case empty origin is passed, remove it. Some services do not like Origin header
// so this way we can remove it in explicit way. The default behaviour is still to set it
if cfg!(feature = "unsafe-headers")
&& headers.get(header::ORIGIN) == Some(&HeaderValue::from_static(""))
{
headers.remove(header::ORIGIN);
};
if let Some(data) = data {
request = request.body(data);
}
request = request.headers(headers);
#[cfg(feature = "tracing")]
tracing::trace!("{:?}", request);
let fut = async move { request.send().await.map_err(map_request_error) };
let mut resources_table = webview.resources_table();
let rid = resources_table.add_request(Box::pin(fut));
Ok(rid)
}
"data" => {
let data_url =
data_url::DataUrl::process(url.as_str()).map_err(|_| Error::DataUrlError)?;
let (body, _) = data_url
.decode_to_vec()
.map_err(|_| Error::DataUrlDecodeError)?;
let response = http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, data_url.mime_type().to_string())
.body(reqwest::Body::from(body))?;
#[cfg(feature = "tracing")]
tracing::trace!("{:?}", response);
let fut = async move { Ok(reqwest::Response::from(response)) };
let mut resources_table = webview.resources_table();
let rid = resources_table.add_request(Box::pin(fut));
Ok(rid)
}
_ => Err(Error::SchemeNotSupport(scheme.to_string())),
}
}
#[command]
pub fn fetch_cancel<R: Runtime>(webview: Webview<R>, rid: ResourceId) -> crate::Result<()> {
let mut resources_table = webview.resources_table();
let req = resources_table.get::<FetchRequest>(rid)?;
let abort_tx = resources_table.take::<AbortSender>(req.abort_tx_rid)?;
if let Some(abort_tx) = Arc::into_inner(abort_tx) {
abort_tx.abort();
}
Ok(())
}
// Header values are byte strings. Decode as ISO-8859-1 so the raw bytes survive
// as a valid ByteString for `new Headers()` on the JS side.
fn header_value_to_byte_string(value: &HeaderValue) -> String {
value.as_bytes().iter().map(|&b| b as char).collect()
}
// The inverse of `header_value_to_byte_string`: a ByteString from `Headers` on the
// JS side carries one byte per char, so send those bytes as-is like a browser does.
// Values with chars above U+00FF can't come from `Headers`; keep encoding those as UTF-8.
fn byte_string_to_header_value(value: &str) -> crate::Result<HeaderValue> {
let bytes = value
.chars()
.map(|c| u8::try_from(c).ok())
.collect::<Option<Vec<u8>>>();
Ok(match bytes {
Some(bytes) => HeaderValue::from_bytes(&bytes)?,
None => HeaderValue::from_str(value)?,
})
}
#[command]
pub async fn fetch_send<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> crate::Result<FetchResponse> {
let (req, abort_rx) = {
let mut resources_table = webview.resources_table();
let req = resources_table.get::<FetchRequest>(rid)?;
let abort_rx = resources_table.take::<AbortRecveiver>(req.abort_rx_rid)?;
(req, abort_rx)
};
let Some(abort_rx) = Arc::into_inner(abort_rx) else {
return Err(Error::RequestCanceled);
};
let mut fut = req.fut.lock().await;
let res = tokio::select! {
res = fut.as_mut() => res,
_ = abort_rx.0 => Err(Error::RequestCanceled),
};
// Release resources
{
let mut resources_table = webview.resources_table();
let _ = resources_table.take::<AbortSender>(req.abort_tx_rid);
let _ = resources_table.close(rid);
}
let res = res?;
#[cfg(feature = "tracing")]
tracing::trace!("{:?}", res);
let status = res.status();
let url = res.url().to_string();
let mut headers = Vec::new();
for (key, val) in res.headers().iter() {
headers.push((key.as_str().into(), header_value_to_byte_string(val)));
}
let mut resources_table = webview.resources_table();
let rid = resources_table.add(ReqwestResponse(res));
Ok(FetchResponse {
status: status.as_u16(),
status_text: status.canonical_reason().unwrap_or_default().to_string(),
headers,
url,
rid,
})
}
#[command]
pub async fn fetch_read_body<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> crate::Result<tauri::ipc::Response> {
let res = {
let resources_table = webview.resources_table();
resources_table.get::<ReqwestResponse>(rid)?
};
// SAFETY: we can access the inner value mutably
// because we are the only ones with a reference to it
// and we don't want to use `Arc::into_inner` because we want to keep the value in the table
// for potential future calls to `fetch_cancel_body`
let res_ptr = Arc::as_ptr(&res) as *mut ReqwestResponse;
let res = unsafe { &mut *res_ptr };
let res = &mut res.0;
let Some(chunk) = res.chunk().await? else {
let mut resources_table = webview.resources_table();
resources_table.close(rid)?;
// return a response with a single byte to indicate that the body is empty
return Ok(tauri::ipc::Response::new(vec![1]));
};
let mut chunk = chunk.to_vec();
// append a 0 byte to indicate that the body is not empty
chunk.push(0);
Ok(tauri::ipc::Response::new(chunk))
}
#[command]
pub async fn fetch_cancel_body<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> crate::Result<()> {
let mut resources_table = webview.resources_table();
resources_table.close(rid)?;
Ok(())
}
// forbidden headers per fetch spec https://fetch.spec.whatwg.org/#terminology-headers
#[cfg(not(feature = "unsafe-headers"))]
fn is_unsafe_header(header: &HeaderName) -> bool {
matches!(
*header,
header::ACCEPT_CHARSET
| header::ACCEPT_ENCODING
| header::ACCESS_CONTROL_REQUEST_HEADERS
| header::ACCESS_CONTROL_REQUEST_METHOD
| header::CONNECTION
| header::CONTENT_LENGTH
| header::COOKIE
| header::DATE
| header::DNT
| header::EXPECT
| header::HOST
| header::ORIGIN
| header::REFERER
| header::SET_COOKIE
| header::TE
| header::TRAILER
| header::TRANSFER_ENCODING
| header::UPGRADE
| header::VIA
) || {
let lower = header.as_str().to_lowercase();
lower.starts_with("proxy-") || lower.starts_with("sec-")
}
}
#[cfg(test)]
mod tests {
use std::{
io::{BufRead, BufReader, Write},
net::TcpListener,
sync::Arc,
};
use super::*;
/// Test server with an open redirect:
///
/// - `/redirect-external` answers a 302 to `http://127.0.0.1:{port}/secret`, which is a
/// different origin as far as the scope is concerned - the tests allow `localhost`;
/// - `/redirect/{n}` answers a 302 to `/redirect/{n-1}` on `localhost`, and `/redirect/0`
/// answers a 302 to `/target`, so the whole chain stays in scope;
/// - anything else answers a 200, so a bypass shows up as a successful response.
fn spawn_server() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
// the whole request must be consumed, otherwise closing the connection
// with unread data queued resets it before the client reads the response
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
loop {
let mut header = String::new();
reader.read_line(&mut header).unwrap();
if matches!(header.as_str(), "" | "\r\n" | "\n") {
break;
}
}
let path = request_line.split(' ').nth(1).unwrap_or("/");
let location = match path.strip_prefix("/redirect/") {
Some("0") => Some(format!("http://localhost:{port}/target")),
Some(remaining) => Some(format!(
"http://localhost:{port}/redirect/{}",
remaining.parse::<usize>().unwrap() - 1
)),
None if path == "/redirect-external" => {
Some(format!("http://127.0.0.1:{port}/secret"))
}
None => None,
};
let response = match location {
Some(location) => format!(
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
),
None => {
"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nsecret"
.to_string()
}
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
port
}
fn get(
url: &str,
scope: Option<Scope>,
max_redirections: Option<usize>,
) -> Result<reqwest::Response> {
let client = reqwest::ClientBuilder::new()
.redirect(redirect_policy(scope, max_redirections))
.build()
.unwrap();
let request = client.get(url);
tauri::async_runtime::block_on(
async move { request.send().await.map_err(map_request_error) },
)
}
fn localhost_scope(port: u16) -> Option<Scope> {
let entry = Arc::new(format!("http://localhost:{port}/*").parse().unwrap());
Some(Scope::new(vec![entry], Vec::new()))
}
#[test]
fn redirect_outside_of_scope_is_denied() {
let port = spawn_server();
let err = get(
&format!("http://localhost:{port}/redirect-external"),
localhost_scope(port),
None,
)
.unwrap_err();
match err {
Error::UrlNotAllowed(url) => {
assert_eq!(url.as_str(), format!("http://127.0.0.1:{port}/secret"))
}
e => panic!("expected the redirect to be denied by the scope, got {e:?}"),
}
}
#[test]
fn redirect_outside_of_scope_is_followed_when_not_configured() {
let port = spawn_server();
// `scopeRedirects` is disabled, so only the URL requested by the frontend is checked
let response = get(
&format!("http://localhost:{port}/redirect-external"),
None,
None,
)
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn redirect_denied_by_the_scope_is_denied() {
let port = spawn_server();
let allow = Arc::new(format!("http://localhost:{port}/*").parse().unwrap());
let deny = Arc::new(format!("http://localhost:{port}/target").parse().unwrap());
let scope = Some(Scope::new(vec![allow], vec![deny]));
let err = get(&format!("http://localhost:{port}/redirect/0"), scope, None).unwrap_err();
assert!(matches!(err, Error::UrlNotAllowed(_)));
}
#[test]
fn redirect_inside_of_scope_is_followed() {
let port = spawn_server();
let response = get(
&format!("http://localhost:{port}/redirect/2"),
localhost_scope(port),
None,
)
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.url().path(), "/target");
}
#[test]
fn max_redirections_is_enforced() {
let port = spawn_server();
for scope in [localhost_scope(port), None] {
let err = get(
&format!("http://localhost:{port}/redirect/5"),
scope,
Some(2),
)
.unwrap_err();
assert!(matches!(err, Error::Network(e) if e.is_redirect()));
}
}
#[test]
fn zero_max_redirections_does_not_follow() {
let port = spawn_server();
let response = get(
&format!("http://localhost:{port}/redirect/0"),
localhost_scope(port),
Some(0),
)
.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
}
fn assert_byte_string(value: &HeaderValue) {
let decoded = header_value_to_byte_string(value);
assert!(
decoded.chars().all(|c| u32::from(c) <= 255),
"header value must be a JS ByteString (every code point <= 255)"
);
assert_eq!(
decoded.chars().map(|c| c as u8).collect::<Vec<_>>(),
value.as_bytes(),
"ISO-8859-1 decode must preserve the wire bytes"
);
let json = serde_json::to_string(&decoded).unwrap();
let roundtrip: String = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, decoded);
}
#[test]
fn utf8_content_disposition_filename_is_byte_string() {
// Issue #2898: `filename="中文-EN.pdf"` sent as raw UTF-8 on the wire.
let value =
HeaderValue::from_bytes(b"attachment; filename=\"\xe4\xb8\xad\xe6\x96\x87-EN.pdf\"")
.unwrap();
assert_byte_string(&value);
}
#[test]
fn non_utf8_header_bytes_are_preserved() {
let value = HeaderValue::from_bytes(b"x\xff").unwrap();
assert_byte_string(&value);
}
#[test]
fn request_byte_string_is_sent_as_raw_bytes() {
let wire = b"attachment; filename=\"\xe4\xb8\xad\xe6\x96\x87-EN.pdf\"";
let byte_string = header_value_to_byte_string(&HeaderValue::from_bytes(wire).unwrap());
let value = byte_string_to_header_value(&byte_string).unwrap();
assert_eq!(value.as_bytes(), wire);
let value = byte_string_to_header_value("x\u{ff}").unwrap();
assert_eq!(value.as_bytes(), b"x\xff");
}
#[test]
fn request_non_byte_string_falls_back_to_utf8() {
let value = byte_string_to_header_value("中文").unwrap();
assert_eq!(value.as_bytes(), "中文".as_bytes());
}
}