Prompt user for credentials when http request needs it (#34620)

* prompt user to get their credentials

Signed-off-by: Lloyd Massiah artmis9@protonmail.com

move credential prompt to a function

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* add prompt for step 15.4

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* add new prompt definition for user credentials

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* remove default implementation for HttpState which allowed making the embedder_proxy non-optional

- default implementation was only used in tests so created an alternative create_http_state function

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

add credentials to authentication cache

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* add tests that are successful for the happy path

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* add test for user cancels prompt and user inputs incorrect credentials, and refactor shared code between tests

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* handle error when setting username and password in Url and ran formatting

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

renaming test functions

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* change authentication flag to false for proxy authentication. The spec doesn't specify that the flag should be true, and the flag is by default false

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* clean up test code a bit

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* add skeleton implementation to support open harmony and android

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* update warning message to include Android

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* fix build error for OH os and Android

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

* remove unused import to fix warning

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>

---------

Signed-off-by: Lloyd Massiah <artmis9@protonmail.com>
Co-authored-by: lazypassion <25536767+lazypassion@users.noreply.github.com>
This commit is contained in:
arthmis 2024-12-28 15:24:11 -05:00 committed by GitHub
parent a9539d8b03
commit aa40b8f820
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 379 additions and 51 deletions

View file

@ -16,6 +16,9 @@ use devtools_traits::{
ChromeToDevtoolsControlMsg, DevtoolsControlMsg, HttpRequest as DevtoolsHttpRequest,
HttpResponse as DevtoolsHttpResponse, NetworkEvent,
};
use embedder_traits::{
EmbedderMsg, EmbedderProxy, PromptCredentialsInput, PromptDefinition, PromptOrigin,
};
use futures::{future, StreamExt, TryFutureExt, TryStreamExt};
use headers::authorization::Basic;
use headers::{
@ -60,10 +63,7 @@ use tokio::sync::mpsc::{
use tokio_stream::wrappers::ReceiverStream;
use crate::async_runtime::HANDLE;
use crate::connector::{
create_http_client, create_tls_config, CACertificates, CertificateErrorOverrideManager,
Connector,
};
use crate::connector::{CertificateErrorOverrideManager, Connector};
use crate::cookie::ServoCookie;
use crate::cookie_storage::CookieStorage;
use crate::decoder::Decoder;
@ -72,7 +72,7 @@ use crate::fetch::headers::{SecFetchDest, SecFetchMode, SecFetchSite, SecFetchUs
use crate::fetch::methods::{main_fetch, Data, DoneChannel, FetchContext, Target};
use crate::hsts::HstsList;
use crate::http_cache::{CacheKey, HttpCache};
use crate::resource_thread::AuthCache;
use crate::resource_thread::{AuthCache, AuthCacheEntry};
/// <https://fetch.spec.whatwg.org/#document-accept-header-value>
pub const DOCUMENT_ACCEPT_HEADER_VALUE: HeaderValue =
@ -103,26 +103,7 @@ pub struct HttpState {
pub history_states: RwLock<HashMap<HistoryStateId, Vec<u8>>>,
pub client: Client<Connector, Body>,
pub override_manager: CertificateErrorOverrideManager,
}
impl Default for HttpState {
fn default() -> Self {
let override_manager = CertificateErrorOverrideManager::new();
Self {
hsts_list: RwLock::new(HstsList::default()),
cookie_jar: RwLock::new(CookieStorage::new(150)),
auth_cache: RwLock::new(AuthCache::default()),
history_states: RwLock::new(HashMap::new()),
http_cache: RwLock::new(HttpCache::default()),
http_cache_state: Mutex::new(HashMap::new()),
client: create_http_client(create_tls_config(
CACertificates::Default,
false, /* ignore_certificate_errors */
override_manager.clone(),
)),
override_manager,
}
}
pub embedder_proxy: Mutex<EmbedderProxy>,
}
/// Step 13 of <https://fetch.spec.whatwg.org/#concept-fetch>.
@ -1590,12 +1571,26 @@ async fn http_network_or_cache_fetch(
// Step 14.3 If requests use-URL-credentials flag is unset or isAuthenticationFetch is true, then:
if !http_request.use_url_credentials || authentication_fetch_flag {
// TODO(#33616, #27439): Prompt the user for username and password from the window
let Some(credentials) = prompt_user_for_credentials(&context.state.embedder_proxy)
else {
return response;
};
let Some(username) = credentials.username else {
return response;
};
let Some(password) = credentials.password else {
return response;
};
// Wrong, but will have to do until we are able to prompt the user
// otherwise this creates an infinite loop
// We basically pretend that the user declined to enter credentials (#33616)
return response;
if let Err(err) = http_request.current_url_mut().set_username(&username) {
error!("error setting username for url: {:?}", err);
return response;
};
if let Err(err) = http_request.current_url_mut().set_password(Some(&password)) {
error!("error setting password for url: {:?}", err);
return response;
};
}
// Make sure this is set to None,
@ -1627,15 +1622,43 @@ async fn http_network_or_cache_fetch(
// TODO(#33616): Step 15.3 If fetchParams is canceled, then return
// the appropriate network error for fetchParams.
// TODO(#33616): Step 15.4 Prompt the end user as appropriate in requests window and store the
// result as a proxy-authentication entry.
// Step 15.4 Prompt the end user as appropriate in requests window
// window and store the result as a proxy-authentication entry.
let Some(credentials) = prompt_user_for_credentials(&context.state.embedder_proxy) else {
return response;
};
let Some(user_name) = credentials.username else {
return response;
};
let Some(password) = credentials.password else {
return response;
};
// store the credentials as a proxy-authentication entry.
let entry = AuthCacheEntry {
user_name,
password,
};
{
let mut auth_cache = context.state.auth_cache.write().unwrap();
let key = http_request.current_url().origin().ascii_serialization();
auth_cache.entries.insert(key, entry);
}
// Make sure this is set to None,
// since we're about to start a new `http_network_or_cache_fetch`.
*done_chan = None;
// Step 15.5 Set response to the result of running HTTP-network-or-cache fetch given fetchParams.
// Wrong, but will have to do until we are able to prompt the user
// otherwise this creates an infinite loop
// We basically pretend that the user declined to enter credentials (#33616)
return response;
response = http_network_or_cache_fetch(
http_request,
false, /* authentication flag */
cors_flag,
done_chan,
context,
)
.await;
}
// TODO(#33616): Step 16. If all of the following are true:
@ -1737,6 +1760,29 @@ impl Drop for ResponseEndTimer {
}
}
fn prompt_user_for_credentials(
embedder_proxy: &Mutex<EmbedderProxy>,
) -> Option<PromptCredentialsInput> {
let proxy = embedder_proxy.lock().unwrap();
let (ipc_sender, ipc_receiver) = ipc::channel().unwrap();
proxy.send((
None,
EmbedderMsg::Prompt(
PromptDefinition::Credentials(ipc_sender),
PromptOrigin::Trusted,
),
));
let Ok(credentials) = ipc_receiver.recv() else {
warn!("error getting user credentials");
return None;
};
Some(credentials)
}
/// [HTTP network fetch](https://fetch.spec.whatwg.org/#http-network-fetch)
async fn http_network_fetch(
request: &mut Request,

View file

@ -132,7 +132,7 @@ pub fn new_core_resource_thread(
user_agent,
devtools_sender,
time_profiler_chan,
embedder_proxy,
embedder_proxy.clone(),
ca_certificates.clone(),
ignore_certificate_errors,
);
@ -151,6 +151,7 @@ pub fn new_core_resource_thread(
private_setup_port,
report_port,
protocols,
embedder_proxy,
)
},
String::from("network-cache-reporter"),
@ -173,6 +174,7 @@ fn create_http_states(
config_dir: Option<&Path>,
ca_certificates: CACertificates,
ignore_certificate_errors: bool,
embedder_proxy: EmbedderProxy,
) -> (Arc<HttpState>, Arc<HttpState>) {
let mut hsts_list = HstsList::from_servo_preload();
let mut auth_cache = AuthCache::default();
@ -198,6 +200,7 @@ fn create_http_states(
override_manager.clone(),
)),
override_manager,
embedder_proxy: Mutex::new(embedder_proxy.clone()),
};
let override_manager = CertificateErrorOverrideManager::new();
@ -214,6 +217,7 @@ fn create_http_states(
override_manager.clone(),
)),
override_manager,
embedder_proxy: Mutex::new(embedder_proxy),
};
(Arc::new(http_state), Arc::new(private_http_state))
@ -227,11 +231,13 @@ impl ResourceChannelManager {
private_receiver: IpcReceiver<CoreResourceMsg>,
memory_reporter: IpcReceiver<ReportsChan>,
protocols: Arc<ProtocolRegistry>,
embedder_proxy: EmbedderProxy,
) {
let (public_http_state, private_http_state) = create_http_states(
self.config_dir.as_deref(),
self.ca_certificates.clone(),
self.ignore_certificate_errors,
embedder_proxy,
);
let mut rx_set = IpcReceiverSet::new().unwrap();

View file

@ -29,7 +29,6 @@ use net::filemanager_thread::FileManager;
use net::hsts::HstsEntry;
use net::protocols::ProtocolRegistry;
use net::resource_thread::CoreResourceThreadPool;
use net::test::HttpState;
use net_traits::filemanager_thread::FileTokenCheck;
use net_traits::http_status::HttpStatus;
use net_traits::request::{
@ -47,8 +46,8 @@ use uuid::Uuid;
use crate::http_loader::{expect_devtools_http_request, expect_devtools_http_response};
use crate::{
create_embedder_proxy, fetch, fetch_with_context, fetch_with_cors_cache, make_server,
make_ssl_server, new_fetch_context, DEFAULT_USER_AGENT,
create_embedder_proxy, create_http_state, fetch, fetch_with_context, fetch_with_cors_cache,
make_server, make_ssl_server, new_fetch_context, DEFAULT_USER_AGENT,
};
// TODO write a struct that impls Handler for storing test values
@ -669,7 +668,7 @@ fn test_fetch_with_hsts() {
let (server, url) = make_ssl_server(handler);
let mut context = FetchContext {
state: Arc::new(HttpState::default()),
state: Arc::new(create_http_state(None)),
user_agent: DEFAULT_USER_AGENT.into(),
devtools_chan: None,
filemanager: Arc::new(Mutex::new(FileManager::new(
@ -724,7 +723,7 @@ fn test_load_adds_host_to_hsts_list_when_url_is_https() {
url.as_mut_url().set_scheme("https").unwrap();
let mut context = FetchContext {
state: Arc::new(HttpState::default()),
state: Arc::new(create_http_state(None)),
user_agent: DEFAULT_USER_AGENT.into(),
devtools_chan: None,
filemanager: Arc::new(Mutex::new(FileManager::new(
@ -781,7 +780,7 @@ fn test_fetch_self_signed() {
url.as_mut_url().set_scheme("https").unwrap();
let mut context = FetchContext {
state: Arc::new(HttpState::default()),
state: Arc::new(create_http_state(None)),
user_agent: DEFAULT_USER_AGENT.into(),
devtools_chan: None,
filemanager: Arc::new(Mutex::new(FileManager::new(

View file

@ -6,7 +6,6 @@
use std::collections::HashMap;
use std::io::Write;
use std::str;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
@ -47,7 +46,10 @@ use servo_url::{ImmutableOrigin, ServoUrl};
use tokio_test::block_on;
use url::Url;
use crate::{fetch, fetch_with_context, make_server, new_fetch_context};
use crate::{
create_embedder_proxy_and_receiver, fetch, fetch_with_context, make_server, new_fetch_context,
receive_credential_prompt_msgs,
};
fn mock_origin() -> ImmutableOrigin {
ServoUrl::parse("http://servo.org").unwrap().origin()
@ -1479,3 +1481,180 @@ fn test_origin_serialization_compatability() {
ensure_serialiations_match("data:,dataurltexta");
}
#[test]
fn test_user_credentials_prompt_when_proxy_authentication_is_required() {
let handler = move |request: HyperRequest<Body>, response: &mut HyperResponse<Body>| {
let expected = Authorization::basic("username", "test");
if let Some(credentials) = request.headers().typed_get::<Authorization<Basic>>() {
if credentials == expected {
*response.status_mut() = StatusCode::OK;
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
} else {
*response.status_mut() = StatusCode::PROXY_AUTHENTICATION_REQUIRED;
}
};
let (server, url) = make_server(handler);
let mut request = RequestBuilder::new(url.clone(), Referrer::NoReferrer)
.method(Method::GET)
.body(None)
.destination(Destination::Document)
.origin(mock_origin())
.pipeline_id(Some(TEST_PIPELINE_ID))
.credentials_mode(CredentialsMode::Include)
.build();
let (embedder_proxy, embedder_receiver) = create_embedder_proxy_and_receiver();
let _ = receive_credential_prompt_msgs(
embedder_receiver,
Some("username".to_string()),
Some("test".to_string()),
);
let mut context = new_fetch_context(None, Some(embedder_proxy), None);
let response = fetch_with_context(&mut request, &mut context);
let _ = server.close();
assert!(response
.internal_response
.unwrap()
.status
.code()
.is_success());
}
#[test]
fn test_prompt_credentials_when_client_receives_unauthorized_response() {
let handler = move |request: HyperRequest<Body>, response: &mut HyperResponse<Body>| {
let expected = Authorization::basic("username", "test");
if let Some(credentials) = request.headers().typed_get::<Authorization<Basic>>() {
if credentials == expected {
*response.status_mut() = StatusCode::OK;
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
};
let (server, url) = make_server(handler);
let mut request = RequestBuilder::new(url.clone(), Referrer::NoReferrer)
.method(Method::GET)
.body(None)
.destination(Destination::Document)
.origin(mock_origin())
.pipeline_id(Some(TEST_PIPELINE_ID))
.credentials_mode(CredentialsMode::Include)
.build();
let (embedder_proxy, embedder_receiver) = create_embedder_proxy_and_receiver();
let _ = receive_credential_prompt_msgs(
embedder_receiver,
Some("username".to_string()),
Some("test".to_string()),
);
let mut context = new_fetch_context(None, Some(embedder_proxy), None);
let response = fetch_with_context(&mut request, &mut context);
server.close();
assert!(response
.internal_response
.unwrap()
.status
.code()
.is_success());
}
#[test]
fn test_prompt_credentials_user_cancels_dialog_input() {
let handler = move |request: HyperRequest<Body>, response: &mut HyperResponse<Body>| {
let expected = Authorization::basic("username", "test");
if let Some(credentials) = request.headers().typed_get::<Authorization<Basic>>() {
if credentials == expected {
*response.status_mut() = StatusCode::OK;
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
};
let (server, url) = make_server(handler);
let mut request = RequestBuilder::new(url.clone(), Referrer::NoReferrer)
.method(Method::GET)
.body(None)
.destination(Destination::Document)
.origin(mock_origin())
.pipeline_id(Some(TEST_PIPELINE_ID))
.credentials_mode(CredentialsMode::Include)
.build();
let (embedder_proxy, embedder_receiver) = create_embedder_proxy_and_receiver();
let _ = receive_credential_prompt_msgs(embedder_receiver, None, None);
let mut context = new_fetch_context(None, Some(embedder_proxy), None);
let response = fetch_with_context(&mut request, &mut context);
server.close();
assert!(response
.internal_response
.unwrap()
.status
.code()
.is_client_error());
}
#[test]
fn test_prompt_credentials_user_input_incorrect_credentials() {
let handler = move |request: HyperRequest<Body>, response: &mut HyperResponse<Body>| {
let expected = Authorization::basic("username", "test");
if let Some(credentials) = request.headers().typed_get::<Authorization<Basic>>() {
if credentials == expected {
*response.status_mut() = StatusCode::OK;
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
} else {
*response.status_mut() = StatusCode::UNAUTHORIZED;
}
};
let (server, url) = make_server(handler);
let mut request = RequestBuilder::new(url.clone(), Referrer::NoReferrer)
.method(Method::GET)
.body(None)
.destination(Destination::Document)
.origin(mock_origin())
.pipeline_id(Some(TEST_PIPELINE_ID))
.credentials_mode(CredentialsMode::Include)
.build();
let (embedder_proxy, embedder_receiver) = create_embedder_proxy_and_receiver();
let _ = receive_credential_prompt_msgs(
embedder_receiver,
Some("test".to_string()),
Some("test".to_string()),
);
let mut context = new_fetch_context(None, Some(embedder_proxy), None);
let response = fetch_with_context(&mut request, &mut context);
server.close();
assert!(response
.internal_response
.unwrap()
.status
.code()
.is_client_error());
}

View file

@ -19,21 +19,23 @@ mod resource_thread;
mod subresource_integrity;
use core::convert::Infallible;
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufReader};
use std::net::TcpListener as StdTcpListener;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, Weak};
use std::sync::{Arc, LazyLock, Mutex, RwLock, Weak};
use crossbeam_channel::{unbounded, Sender};
use devtools_traits::DevtoolsControlMsg;
use embedder_traits::{EmbedderProxy, EventLoopWaker};
use embedder_traits::{EmbedderProxy, EmbedderReceiver, EventLoopWaker};
use futures::future::ready;
use futures::StreamExt;
use hyper::server::conn::Http;
use hyper::server::Server as HyperServer;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request as HyperRequest, Response as HyperResponse};
use net::connector::{create_http_client, create_tls_config};
use net::fetch::cors_cache::CorsCache;
use net::fetch::methods::{self, CancellationListener, FetchContext};
use net::filemanager_thread::FileManager;
@ -95,6 +97,76 @@ fn create_embedder_proxy() -> EmbedderProxy {
}
}
fn create_embedder_proxy_and_receiver() -> (EmbedderProxy, EmbedderReceiver) {
let (sender, receiver) = unbounded();
let event_loop_waker = || {
struct DummyEventLoopWaker {}
impl DummyEventLoopWaker {
fn new() -> DummyEventLoopWaker {
DummyEventLoopWaker {}
}
}
impl embedder_traits::EventLoopWaker for DummyEventLoopWaker {
fn wake(&self) {}
fn clone_box(&self) -> Box<dyn embedder_traits::EventLoopWaker> {
Box::new(DummyEventLoopWaker {})
}
}
Box::new(DummyEventLoopWaker::new())
};
let embedder_proxy = embedder_traits::EmbedderProxy {
sender: sender.clone(),
event_loop_waker: event_loop_waker(),
};
let embedder_receiver = EmbedderReceiver { receiver };
(embedder_proxy, embedder_receiver)
}
fn receive_credential_prompt_msgs(
mut embedder_receiver: EmbedderReceiver,
username: Option<String>,
password: Option<String>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let (_browser_context_id, embedder_msg) = embedder_receiver.recv_embedder_msg();
match embedder_msg {
embedder_traits::EmbedderMsg::Prompt(prompt_definition, _prompt_origin) => {
match prompt_definition {
embedder_traits::PromptDefinition::Credentials(ipc_sender) => {
ipc_sender
.send(embedder_traits::PromptCredentialsInput { username, password })
.unwrap();
},
_ => unreachable!(),
}
},
_ => unreachable!(),
}
})
}
fn create_http_state(fc: Option<EmbedderProxy>) -> HttpState {
let override_manager = net::connector::CertificateErrorOverrideManager::new();
HttpState {
hsts_list: RwLock::new(net::hsts::HstsList::default()),
cookie_jar: RwLock::new(net::cookie_storage::CookieStorage::new(150)),
auth_cache: RwLock::new(net::resource_thread::AuthCache::default()),
history_states: RwLock::new(HashMap::new()),
http_cache: RwLock::new(net::http_cache::HttpCache::default()),
http_cache_state: Mutex::new(HashMap::new()),
client: create_http_client(create_tls_config(
net::connector::CACertificates::Default,
false, /* ignore_certificate_errors */
override_manager.clone(),
)),
override_manager,
embedder_proxy: Mutex::new(fc.unwrap_or_else(|| create_embedder_proxy())),
}
}
fn new_fetch_context(
dc: Option<Sender<DevtoolsControlMsg>>,
fc: Option<EmbedderProxy>,
@ -103,7 +175,7 @@ fn new_fetch_context(
let sender = fc.unwrap_or_else(|| create_embedder_proxy());
FetchContext {
state: Arc::new(HttpState::default()),
state: Arc::new(create_http_state(Some(sender.clone()))),
user_agent: DEFAULT_USER_AGENT.into(),
devtools_chan: dc.map(|dc| Arc::new(Mutex::new(dc))),
filemanager: Arc::new(Mutex::new(FileManager::new(

View file

@ -131,6 +131,16 @@ pub enum PromptDefinition {
YesNo(String, IpcSender<PromptResult>),
/// Ask the user to enter text.
Input(String, String, IpcSender<Option<String>>),
/// Ask user to enter their username and password
Credentials(IpcSender<PromptCredentialsInput>),
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PromptCredentialsInput {
/// Username for http request authentication
pub username: Option<String>,
/// Password for http request authentication
pub password: Option<String>,
}
#[derive(Deserialize, PartialEq, Serialize)]

View file

@ -21,8 +21,8 @@ use servo::base::id::TopLevelBrowsingContextId as WebViewId;
use servo::compositing::windowing::{EmbedderEvent, WebRenderDebugOption};
use servo::embedder_traits::{
CompositorEventVariant, ContextMenuResult, DualRumbleEffectParams, EmbedderMsg, FilterPattern,
GamepadHapticEffectType, PermissionPrompt, PermissionRequest, PromptDefinition, PromptOrigin,
PromptResult,
GamepadHapticEffectType, PermissionPrompt, PermissionRequest, PromptCredentialsInput,
PromptDefinition, PromptOrigin, PromptResult,
};
use servo::ipc_channel::ipc::IpcSender;
use servo::script_traits::{
@ -697,6 +697,12 @@ where
PromptDefinition::Input(_message, default, sender) => {
sender.send(Some(default.to_owned()))
},
PromptDefinition::Credentials(sender) => {
sender.send(PromptCredentialsInput {
username: None,
password: None,
})
},
}
} else {
thread::Builder::new()
@ -751,6 +757,12 @@ where
let result = tinyfiledialogs::input_box("", &message, &default);
sender.send(result)
},
PromptDefinition::Credentials(sender) => {
// TODO: figure out how to make the message a localized string
let username = tinyfiledialogs::input_box("", "username", "");
let password = tinyfiledialogs::input_box("", "password", "");
sender.send(PromptCredentialsInput { username, password })
},
})
.unwrap()
.join()

View file

@ -512,6 +512,10 @@ impl ServoGlue {
PromptDefinition::Input(message, default, sender) => {
sender.send(cb.prompt_input(message, default, trusted))
},
PromptDefinition::Credentials(_) => {
warn!("implement credentials prompt for OpenHarmony OS and Android");
Ok(())
},
};
if let Err(e) = res {
let reason = format!("Failed to send Prompt response: {}", e);