use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use ureq::SendBody;
use ureq::http::HeaderMap;
use ytsaurus_yson::{YsonFormat, YsonValue, to_string};
use crate::error::{ClientError, RedirectRefusal, Result, truncate};
use crate::retry::{MutationId, Repeatable, RetryPolicy};
use crate::yson_build::{boolean, insert, string};
const HEADER_FORMAT: &str = "X-YT-Header-Format";
const PARAMETERS: &str = "X-YT-Parameters";
const ERROR: &str = "X-YT-Error";
const LOCATION: &str = "Location";
const MAX_REDIRECTS: usize = 10;
const RESPONSE_LIMIT: u64 = 512 * 1024 * 1024;
const HEAVY: &[&str] = &[
"read_table",
"write_table",
"read_file",
"write_file",
"read_blob_table",
"get_job_input",
"get_job_stderr",
];
pub(crate) fn is_heavy(command: &str) -> bool {
HEAVY.contains(&command)
}
const TRACEPARENT: &str = "traceparent";
const TRACESTATE: &str = "tracestate";
const TRANSACTION_ID: &str = "transaction_id";
#[cfg(feature = "tls")]
const CA_BUNDLE: &str = "YT_CA_BUNDLE";
#[cfg(feature = "tls")]
const MAX_BUNDLE_BYTES: u64 = 16 * 1024 * 1024;
pub(crate) const CONTROL_REFUSAL: &str = "may not serve heavy requests";
const NO_TRANSACTION: &[&str] = &[
"execute_batch",
"get_operation",
"list_operations",
"list_operation_events",
"abort_operation",
"complete_operation",
"suspend_operation",
"resume_operation",
"update_operation_parameters",
"list_jobs",
"get_job",
"get_job_stderr",
"get_job_input",
"abort_job",
"poll_job_shell",
];
pub(crate) fn takes_no_transaction(command: &str) -> bool {
NO_TRANSACTION.contains(&command)
}
macro_rules! with_headers {
($request:expr $(, $headers:expr)* $(,)?) => {{
let mut request = $request;
$(
for (name, value) in $headers {
request = request.header(*name, value.as_str());
}
)*
request
}};
}
pub(crate) enum Payload<'a> {
None,
Bytes(&'a [u8]),
}
enum Outgoing<'a> {
Empty,
Bytes(&'a [u8]),
Stream(&'a mut dyn std::io::Read),
}
impl Outgoing<'_> {
fn replayable(&self) -> bool {
!matches!(self, Outgoing::Stream(_))
}
fn carries_data(&self) -> bool {
match self {
Outgoing::Empty => false,
Outgoing::Bytes(bytes) => !bytes.is_empty(),
Outgoing::Stream(_) => true,
}
}
}
const HOSTS_TIMEOUT: Duration = Duration::from_millis(800);
const HOSTS_RETRY_AFTER: Duration = Duration::from_secs(10);
const HOST_LIST_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug)]
enum HeavyProxy {
Unasked,
Pool(HeavyPool),
Configured {
asked: Instant,
},
FellBack {
until: Option<Instant>,
},
}
#[derive(Debug)]
struct HeavyPool {
hosts: Vec<String>,
fetched: Instant,
}
impl HeavyPool {
fn pick(&self) -> &str {
let drawn = crate::unique::word(0) % self.hosts.len() as u64;
&self.hosts[drawn as usize]
}
#[must_use]
fn drop_host(&mut self, base: &str) -> bool {
self.hosts.retain(|host| host != base);
!self.hosts.is_empty()
}
}
enum Destination<'a> {
Discovered(String),
Configured(&'a str),
}
impl Destination<'_> {
fn address(&self) -> &str {
match self {
Self::Discovered(base) => base,
Self::Configured(base) => base,
}
}
}
#[derive(Clone, Debug)]
enum HeavyHosts {
SameDomain,
Under {
domains: Vec<String>,
ignored: Vec<String>,
},
Anywhere,
Only(Vec<String>),
}
impl HeavyHosts {
fn admits(&self, configured: &str, discovered: &str) -> bool {
match self {
Self::SameDomain => same_domain(host_of(configured), host_of(discovered)),
Self::Under { domains, .. } => {
same_domain(host_of(configured), host_of(discovered))
|| domains
.iter()
.any(|domain| under_domain(domain, host_of(discovered)))
}
Self::Anywhere => true,
Self::Only(names) => names.iter().any(|name| same_name(name, discovered)),
}
}
}
fn same_name(listed: &str, discovered: &str) -> bool {
let listed = listed.trim();
if !host_of(listed).eq_ignore_ascii_case(host_of(discovered)) {
return false;
}
match (port_of(listed), port_of(discovered)) {
(Some(listed), Some(discovered)) => listed == discovered,
_ => true,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Declined {
Malformed,
Elsewhere,
}
impl Declined {
fn because(self, allowed: &HeavyHosts, configured: &str) -> String {
match (self, allowed) {
(Self::Malformed, _) => "is not a host name".to_owned(),
(Self::Elsewhere, HeavyHosts::Only(_)) => {
"is not one of the names with_heavy_proxies_in was given".to_owned()
}
(Self::Elsewhere, HeavyHosts::Under { domains, ignored })
if !domains.is_empty() || !ignored.is_empty() =>
{
let mut why = format!("is not under the domain of {}", host_of(configured));
if !domains.is_empty() {
why.push_str(&format!(" or under {}", domains.join(", ")));
}
if !ignored.is_empty() {
why.push_str(&format!(" (ignored, not a domain: {})", ignored.join(", ")));
}
why
}
(Self::Elsewhere, _) => {
format!("is not under the domain of {}", host_of(configured))
}
}
}
}
#[derive(Clone)]
pub(crate) struct Transport {
agent: ureq::Agent,
base: String,
heavy: Arc<Mutex<HeavyProxy>>,
discovery: bool,
hosts: HeavyHosts,
hosts_timeout: Duration,
hosts_retry_after: Duration,
host_list_refresh: Duration,
token: Option<String>,
retries: RetryPolicy,
timeout: Duration,
response_limit: u64,
transaction: Option<String>,
trace: Option<String>,
tracestate: Option<String>,
caller: Vec<(&'static str, String)>,
tls_refused: Option<String>,
}
impl std::fmt::Debug for Transport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Transport")
.field("base", &self.base)
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl Transport {
pub(crate) fn new(proxy: &str, token: Option<String>, timeout: Duration) -> Self {
let base = if proxy.starts_with("http://") || proxy.starts_with("https://") {
proxy.trim_end_matches('/').to_owned()
} else {
format!("https://{}", proxy.trim_end_matches('/'))
};
let retries = if crate::retry::report_by_default() {
RetryPolicy::default()
} else {
RetryPolicy::default().quiet()
};
let (agent, tls_refused) = build_agent(timeout, configured_bundle());
let mut transport = Self {
agent,
discovery: !is_local(&base),
hosts: HeavyHosts::SameDomain,
hosts_timeout: HOSTS_TIMEOUT,
hosts_retry_after: HOSTS_RETRY_AFTER,
host_list_refresh: HOST_LIST_REFRESH_INTERVAL,
base,
heavy: Arc::new(Mutex::new(HeavyProxy::Unasked)),
token,
retries,
timeout,
response_limit: RESPONSE_LIMIT,
transaction: None,
trace: None,
tracestate: None,
caller: Vec::new(),
tls_refused,
};
transport.render_caller_headers();
transport
}
pub(crate) fn set_retries(&mut self, policy: RetryPolicy) {
self.retries = policy;
}
pub(crate) fn set_proxy_discovery(&mut self, enabled: bool) {
self.discovery = enabled;
self.forget_heavy();
}
pub(crate) fn set_heavy_proxies_anywhere(&mut self, enabled: bool) {
self.hosts = if enabled {
HeavyHosts::Anywhere
} else {
HeavyHosts::SameDomain
};
self.forget_heavy();
}
pub(crate) fn set_heavy_proxies_in(&mut self, names: Vec<String>) {
self.hosts = HeavyHosts::Only(names);
self.forget_heavy();
}
pub(crate) fn set_heavy_proxies_under(&mut self, domains: Vec<String>) {
let mut kept: Vec<String> = Vec::with_capacity(domains.len());
let mut ignored: Vec<String> = Vec::new();
for domain in &domains {
let normalised = host_of(domain.trim())
.trim_start_matches('*')
.trim_matches('.')
.to_ascii_lowercase();
if normalised.is_empty() {
continue;
}
let (into, value) = if normalised.contains('.') {
(&mut kept, normalised)
} else {
(&mut ignored, domain.trim().to_owned())
};
if !into.contains(&value) {
into.push(value);
}
}
self.hosts = HeavyHosts::Under {
domains: kept,
ignored,
};
self.forget_heavy();
}
pub(crate) fn set_hosts_timeout(&mut self, timeout: Duration) {
self.hosts_timeout = timeout;
}
pub(crate) fn set_hosts_retry_after(&mut self, after: Duration) {
self.hosts_retry_after = after;
}
pub(crate) fn set_host_list_refresh_interval(&mut self, interval: Duration) {
self.host_list_refresh = interval;
}
#[cfg(test)]
pub(crate) fn configured_address(&self) -> &str {
&self.base
}
#[cfg(test)]
pub(crate) fn heavy_hosts_debug(&self) -> String {
format!("{:?}", self.hosts)
}
#[cfg(test)]
pub(crate) fn set_response_limit(&mut self, limit: u64) {
self.response_limit = limit;
}
fn forget_heavy(&mut self) {
self.heavy = Arc::new(Mutex::new(HeavyProxy::Unasked));
}
pub(crate) fn set_timeout(&mut self, timeout: Duration) {
self.timeout = timeout;
let (agent, tls_refused) = build_agent(timeout, configured_bundle());
self.agent = agent;
self.tls_refused = tls_refused;
}
pub(crate) fn set_transaction(&mut self, id: Option<String>) {
self.transaction = id;
}
pub(crate) fn transaction(&self) -> Option<&str> {
self.transaction.as_deref()
}
pub(crate) fn set_trace(&mut self, context: &crate::TraceContext) {
self.trace = Some(context.header());
self.tracestate = context.tracestate().map(str::to_owned);
self.render_caller_headers();
}
pub(crate) fn trace(&self) -> Option<&str> {
self.trace.as_deref()
}
pub(crate) fn tracestate(&self) -> Option<&str> {
self.tracestate.as_deref()
}
pub(crate) fn call(
&self,
method: Method,
command: &str,
parameters: &YsonValue,
payload: Payload<'_>,
repeatable: Repeatable,
) -> Result<Vec<u8>> {
self.call_with(method, command, parameters, payload, repeatable, None)
}
pub(crate) fn call_with(
&self,
method: Method,
command: &str,
parameters: &YsonValue,
payload: Payload<'_>,
repeatable: Repeatable,
mutation_id: Option<&MutationId>,
) -> Result<Vec<u8>> {
let mutation_id = match (repeatable, mutation_id) {
(_, Some(given)) => Some(given.clone()),
(Repeatable::WithMutationId, None) => Some(MutationId::new()),
_ => None,
};
let stamped = self.in_transaction(command, parameters);
let parameters = stamped.as_ref().unwrap_or(parameters);
let base = self.base_for(repeatable);
let sent = crate::retry::run(self.retries, repeatable, command, |is_retry| {
match &mutation_id {
Some(id) => {
let mut tagged = parameters.clone();
insert(&mut tagged, "mutation_id", string(id.as_str()));
insert(&mut tagged, "retry", boolean(is_retry || id.is_retry()));
self.send(base.address(), method, command, &tagged, &payload)
}
None => self.send(base.address(), method, command, parameters, &payload),
}
});
self.after_heavy(repeatable, &base, sent)
}
fn in_transaction(&self, command: &str, parameters: &YsonValue) -> Option<YsonValue> {
let id = self.transaction.as_ref()?;
if takes_no_transaction(command) {
return None;
}
if let ytsaurus_yson::YsonNode::Map(m) = ¶meters.node
&& m.contains_key(TRANSACTION_ID.as_bytes())
{
return None;
}
let mut tagged = parameters.clone();
insert(&mut tagged, TRANSACTION_ID, string(id));
Some(tagged)
}
fn base_for(&self, repeatable: Repeatable) -> Destination<'_> {
if repeatable != Repeatable::Heavy || !self.discovery {
return Destination::Configured(&self.base);
}
let mut resolved = lock(&self.heavy);
match &mut *resolved {
HeavyProxy::Pool(pool) => {
if pool.fetched.elapsed() >= self.host_list_refresh {
match self.usable_hosts() {
Ok(hosts) if !hosts.is_empty() => {
*pool = HeavyPool {
hosts,
fetched: Instant::now(),
};
}
_ => pool.fetched = Instant::now(),
}
}
return Destination::Discovered(pool.pick().to_owned());
}
HeavyProxy::Configured { asked } if asked.elapsed() < self.host_list_refresh => {
return Destination::Configured(&self.base);
}
HeavyProxy::FellBack { until } if until.is_none_or(|until| Instant::now() < until) => {
return Destination::Configured(&self.base);
}
HeavyProxy::Unasked | HeavyProxy::Configured { .. } | HeavyProxy::FellBack { .. } => {}
}
let first_asking = matches!(&*resolved, HeavyProxy::Unasked);
match self.heavy_hosts() {
Ok(hosts) => {
let (usable, refused) = self.admitted(&hosts);
if usable.is_empty() {
*resolved = HeavyProxy::Configured {
asked: Instant::now(),
};
drop(resolved);
if first_asking && !refused.is_empty() && self.retries.reports() {
crate::observe::declined(&self.base, &refused);
}
return Destination::Configured(&self.base);
}
let pool = HeavyPool {
hosts: usable,
fetched: Instant::now(),
};
let picked = pool.pick().to_owned();
*resolved = HeavyProxy::Pool(pool);
Destination::Discovered(picked)
}
Err(error) => {
*resolved = if crate::retry::worth_asking_again(&error) {
HeavyProxy::FellBack {
until: Instant::now().checked_add(self.hosts_retry_after),
}
} else {
HeavyProxy::Configured {
asked: Instant::now(),
}
};
Destination::Configured(&self.base)
}
}
}
fn admitted(&self, hosts: &[String]) -> (Vec<String>, Vec<String>) {
let mut usable = Vec::new();
let mut refused = Vec::new();
for host in hosts {
match heavy_base(&self.base, host, &self.hosts) {
Ok(base) => usable.push(base),
Err(why) => {
refused.push(format!("{host:?} {}", why.because(&self.hosts, &self.base)));
}
}
}
(usable, refused)
}
fn usable_hosts(&self) -> Result<Vec<String>> {
Ok(self
.heavy_hosts()?
.iter()
.filter_map(|host| heavy_base(&self.base, host, &self.hosts).ok())
.collect())
}
pub(crate) fn heavy_hosts(&self) -> Result<Vec<String>> {
let body = self.fetch("/hosts", "hosts")?;
serde_json::from_str(&body).map_err(|e| ClientError::Decode {
command: "hosts".to_owned(),
reason: format!(
"/hosts did not answer with a list of host names: {e}; body was {}",
truncate(&body, 200)
),
})
}
fn after_heavy<T>(
&self,
repeatable: Repeatable,
destination: &Destination<'_>,
result: Result<T>,
) -> Result<T> {
if repeatable != Repeatable::Heavy {
return result;
}
let Err(error) = result else {
return result;
};
if !self.discovery {
return Err(refusal_hint(
error,
"this client does not route heavy commands: \
Client::with_proxy_discovery(true) turns the /hosts lookup on",
));
}
let base = match destination {
Destination::Configured(_) => {
let resolved = lock(&self.heavy);
let why = declined_routing(&resolved);
return Err(refusal_hint(error, why));
}
Destination::Discovered(base) => base,
};
if crate::retry::attributable_to_the_host(&error) {
let mut resolved = lock(&self.heavy);
if let HeavyProxy::Pool(pool) = &mut *resolved
&& !pool.drop_host(base)
{
*resolved = HeavyProxy::FellBack {
until: Instant::now().checked_add(self.hosts_retry_after),
};
}
}
Err(routed_to(error, base))
}
fn send(
&self,
base: &str,
method: Method,
command: &str,
parameters: &YsonValue,
payload: &Payload<'_>,
) -> Result<Vec<u8>> {
let body = match payload {
Payload::None => Outgoing::Bytes(&[]),
Payload::Bytes(bytes) => Outgoing::Bytes(bytes),
};
let mut response = self.dispatch(base, method, command, parameters, body, false)?;
let status = response.status().as_u16();
let body = read_capped(command, response.body_mut(), self.response_limit)?;
if !(200..300).contains(&status) {
return Err(ClientError::Http {
command: command.to_owned(),
status,
body: truncate(&String::from_utf8_lossy(&body), 400),
});
}
Ok(body)
}
pub(crate) fn open(
&self,
method: Method,
command: &str,
parameters: &YsonValue,
) -> Result<ureq::Body> {
let stamped = self.in_transaction(command, parameters);
let parameters = stamped.as_ref().unwrap_or(parameters);
let base = self.base_for(Repeatable::Heavy);
let opened = crate::retry::run(self.retries, Repeatable::Heavy, command, |_| {
let response = self.dispatch(
base.address(),
method,
command,
parameters,
Outgoing::Empty,
true,
)?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let mut response = response;
let body = response.body_mut().read_to_string().unwrap_or_default();
return Err(ClientError::Http {
command: command.to_owned(),
status,
body: truncate(&body, 400),
});
}
Ok(response.into_body())
});
self.after_heavy(Repeatable::Heavy, &base, opened)
}
pub(crate) fn upload(
&self,
method: Method,
command: &str,
parameters: &YsonValue,
rows: &mut dyn std::io::Read,
) -> Result<Vec<u8>> {
let stamped = self.in_transaction(command, parameters);
let parameters = stamped.as_ref().unwrap_or(parameters);
let base = self.base_for(Repeatable::Heavy);
let sent = crate::retry::run(self.retries, Repeatable::Heavy, command, |_| {
let mut response = self.dispatch(
base.address(),
method,
command,
parameters,
Outgoing::Stream(&mut *rows),
true,
)?;
let status = response.status().as_u16();
let body = match read_capped(command, response.body_mut(), self.response_limit) {
Ok(body) => body,
Err(error @ ClientError::ResponseTooLarge { .. }) => return Err(error),
Err(_) => Vec::new(),
};
if !(200..300).contains(&status) {
return Err(ClientError::Http {
command: command.to_owned(),
status,
body: truncate(&String::from_utf8_lossy(&body), 400),
});
}
Ok(body)
});
self.after_heavy(Repeatable::Heavy, &base, sent)
}
pub(crate) fn fetch(&self, path: &str, what: &str) -> Result<String> {
if let Some(error) = self.unusable(&self.base) {
return Err(error);
}
let first = format!("{}{path}", self.base);
crate::retry::run(RetryPolicy::none(), Repeatable::Freely, what, |_| {
let mut url = first.clone();
let mut hops = 0;
let deadline = Instant::now().checked_add(self.hosts_timeout);
let mut response = loop {
let left = remaining(deadline, what)?;
let response =
with_headers!(self.scoped(self.agent.get(&url), false, left), &self.caller)
.call()
.map_err(|e| ClientError::Transport {
command: what.to_owned(),
source: Box::new(e),
})?;
match self.redirect(what, &response, &url, &Outgoing::Empty, hops)? {
Some(next) => {
if let Some(error) = self.unusable(&next) {
return Err(error);
}
url = next;
hops += 1;
}
None => break response,
}
};
let status = response.status().as_u16();
let body = response
.body_mut()
.read_to_string()
.map_err(|e| ClientError::Transport {
command: what.to_owned(),
source: Box::new(e),
})?;
if !(200..300).contains(&status) {
return Err(ClientError::Http {
command: what.to_owned(),
status,
body: truncate(&body, 400),
});
}
Ok(body)
})
}
fn dispatch(
&self,
base: &str,
method: Method,
command: &str,
parameters: &YsonValue,
mut body: Outgoing<'_>,
streaming: bool,
) -> Result<ureq::http::Response<ureq::Body>> {
if let Some(error) = self.unusable(base) {
return Err(error);
}
let mut url = format!("{base}/api/v4/{command}");
let encoded = to_string(parameters, YsonFormat::Text).map_err(|e| ClientError::Decode {
command: command.to_owned(),
reason: format!("could not encode parameters: {e}"),
})?;
let headers: [(&str, String); 4] = [
(HEADER_FORMAT, "<format=text>yson".to_owned()),
(PARAMETERS, encoded),
("X-YT-Output-Format", "<format=text>yson".to_owned()),
("Content-Type", "application/octet-stream".to_owned()),
];
let deadline = self.deadline(streaming);
let mut hops = 0;
loop {
let left = remaining(deadline, command)?;
let sent = match method {
Method::Get => with_headers!(
self.scoped(self.agent.get(&url), streaming, left),
&headers,
&self.caller
)
.call(),
Method::Post | Method::Put => {
let request = with_headers!(
self.scoped(
match method {
Method::Put => self.agent.put(&url),
_ => self.agent.post(&url),
},
streaming,
left
),
&headers,
&self.caller
);
match &mut body {
Outgoing::Empty => request.send(SendBody::none()),
Outgoing::Bytes(bytes) => request.send(*bytes),
Outgoing::Stream(reader) => {
request.send(SendBody::from_reader(&mut **reader))
}
}
}
};
let response = sent.map_err(|e| ClientError::Transport {
command: command.to_owned(),
source: Box::new(e),
})?;
if let Some(next) = self.redirect(command, &response, &url, &body, hops)? {
if let Some(error) = tls_unavailable(&next) {
return Err(error);
}
url = next;
hops += 1;
continue;
}
if let Some(raw) = header_value(response.headers(), ERROR) {
return Err(ClientError::from_yt_error(
command,
response.status().as_u16(),
&raw,
));
}
return Ok(response);
}
}
fn redirect(
&self,
command: &str,
response: &ureq::http::Response<ureq::Body>,
request_url: &str,
body: &Outgoing<'_>,
hops: usize,
) -> Result<Option<String>> {
let status = response.status();
if !status.is_redirection() {
return Ok(None);
}
let Some(location) = header_value(response.headers(), LOCATION) else {
return Ok(None);
};
let Some(target) = resolve(request_url, &location) else {
return Ok(None);
};
let refused = |refusal| {
Err(ClientError::Redirected {
command: command.to_owned(),
status: status.as_u16(),
location: target.clone(),
refusal,
heavy: is_heavy(command),
})
};
let elsewhere = !same_origin(request_url, &target);
if self.token.is_some() && elsewhere {
return refused(RedirectRefusal::Credentials);
}
if !body.replayable() {
return refused(RedirectRefusal::Body);
}
if elsewhere && body.carries_data() {
return refused(RedirectRefusal::Payload);
}
if hops >= MAX_REDIRECTS {
return refused(RedirectRefusal::TooMany);
}
Ok(Some(target))
}
fn render_caller_headers(&mut self) {
let mut headers = Vec::new();
if let Some(token) = &self.token {
headers.push(("Authorization", format!("OAuth {token}")));
}
if let Some(trace) = &self.trace {
headers.push((TRACEPARENT, trace.clone()));
}
if let (Some(_), Some(state)) = (&self.trace, &self.tracestate) {
headers.push((TRACESTATE, state.clone()));
}
self.caller = headers;
}
fn unusable(&self, base: &str) -> Option<ClientError> {
if let Some(error) = tls_unavailable(base) {
return Some(error);
}
match &self.tls_refused {
Some(why) if base.starts_with("https://") => Some(ClientError::Config(why.clone())),
_ => None,
}
}
fn deadline(&self, streaming: bool) -> Option<Instant> {
if streaming {
return None;
}
Instant::now().checked_add(self.timeout)
}
fn scoped<Any>(
&self,
request: ureq::RequestBuilder<Any>,
streaming: bool,
left: Option<Duration>,
) -> ureq::RequestBuilder<Any> {
if !streaming {
return match left {
Some(left) => request.config().timeout_global(Some(left)).build(),
None => request,
};
}
request
.config()
.timeout_global(None)
.timeout_resolve(Some(self.timeout))
.timeout_connect(Some(self.timeout))
.timeout_send_request(Some(self.timeout))
.timeout_recv_response(Some(self.timeout))
.build()
}
}
fn lock(heavy: &Mutex<HeavyProxy>) -> MutexGuard<'_, HeavyProxy> {
heavy
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn heavy_base(
configured: &str,
host: &str,
allowed: &HeavyHosts,
) -> std::result::Result<String, Declined> {
let host = host.trim();
if host.is_empty()
|| host.contains("://")
|| host.contains('/')
|| host.contains('@')
|| host.contains(['?', '#'])
|| host.chars().any(char::is_whitespace)
|| !is_authority(host)
{
return Err(Declined::Malformed);
}
if !allowed.admits(configured, host) {
return Err(Declined::Elsewhere);
}
let scheme = if configured.starts_with("https://") {
"https://"
} else {
"http://"
};
Ok(match (has_port(host), port_of(configured)) {
(false, Some(port)) => format!("{scheme}{host}:{port}"),
_ => format!("{scheme}{host}"),
})
}
fn is_authority(host: &str) -> bool {
match host.strip_prefix('[') {
Some(rest) => match rest.split_once(']') {
Some((literal, tail)) => {
literal.parse::<std::net::Ipv6Addr>().is_ok()
&& (tail.is_empty() || tail.strip_prefix(':').is_some_and(is_port))
}
None => false,
},
None => match host.split_once(':') {
Some((name, port)) => !name.is_empty() && is_port(port),
None => true,
},
}
}
fn is_port(port: &str) -> bool {
!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())
}
fn same_domain(configured: &str, discovered: &str) -> bool {
let configured = configured.to_ascii_lowercase();
let discovered = discovered.to_ascii_lowercase();
if configured == discovered {
return true;
}
if configured.parse::<std::net::IpAddr>().is_ok()
|| discovered.parse::<std::net::IpAddr>().is_ok()
{
return false;
}
let domain = match configured.split_once('.') {
Some((_, parent)) if parent.contains('.') => parent,
Some(_) => configured.as_str(),
None => {
return discovered
.split('.')
.skip(1)
.any(|label| label == configured);
}
};
discovered == domain || discovered.ends_with(&format!(".{domain}"))
}
fn under_domain(domain: &str, discovered: &str) -> bool {
let discovered = discovered.to_ascii_lowercase();
discovered == domain || discovered.ends_with(&format!(".{domain}"))
}
fn has_port(authority: &str) -> bool {
match authority.split_once(']') {
Some((_, rest)) => rest.starts_with(':'),
None => authority.contains(':'),
}
}
fn port_of(base: &str) -> Option<&str> {
let authority = authority_of(base);
let port = match authority.split_once(']') {
Some((_, rest)) => rest.strip_prefix(':')?,
None => authority.split_once(':').map(|(_, port)| port)?,
};
(!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())).then_some(port)
}
fn authority_of(base: &str) -> &str {
let authority = base
.split_once("://")
.map_or(base, |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
authority.rsplit_once('@').map_or(authority, |(_, h)| h)
}
fn declined_routing(state: &HeavyProxy) -> &'static str {
match state {
HeavyProxy::Configured { .. } => {
"/hosts named no heavy proxy this client would use — \
Client::with_heavy_proxies_under([…]) or YT_HEAVY_PROXY_DOMAINS \
names the domain they are in, Client::with_heavy_proxies_in([…]) \
names the proxies themselves, and \
Client::with_heavy_proxies_anywhere(true) or \
YT_HEAVY_PROXIES_ANYWHERE=1 allows any name it refused"
}
HeavyProxy::FellBack { .. } => {
"the heavy proxies /hosts named have all just failed, \
so this went to the configured address for a moment"
}
HeavyProxy::Unasked | HeavyProxy::Pool(_) => "this client did not route this command",
}
}
fn refusal_hint(error: ClientError, why: &str) -> ClientError {
match error {
ClientError::Cluster {
command,
code,
message,
raw,
} if message.contains(CONTROL_REFUSAL) => ClientError::Cluster {
command,
code,
message: format!("{message} ({why})"),
raw,
},
other => other,
}
}
struct CapReader<R> {
reader: R,
limit: u64,
left: u64,
}
impl<R> CapReader<R> {
fn new(reader: R, limit: u64) -> Self {
CapReader {
reader,
limit,
left: limit,
}
}
}
impl<R: std::io::Read> std::io::Read for CapReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let room = self.left.saturating_add(1).min(buf.len() as u64) as usize;
let read = self.reader.read(&mut buf[..room])?;
if read as u64 > self.left {
return Err(ureq::Error::BodyExceedsLimit(self.limit).into_io());
}
self.left -= read as u64;
Ok(read)
}
}
fn wire_budget(limit: u64) -> u64 {
limit
.saturating_add(limit >> 3)
.saturating_add(limit >> 6)
.saturating_add(64)
}
fn read_capped(command: &str, body: &mut ureq::Body, limit: u64) -> Result<Vec<u8>> {
use std::io::Read;
let transferred = wire_budget(limit);
let mut reader = CapReader::new(body.with_config().limit(transferred).reader(), limit);
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(|e| body_failure(command, limit, e.into()))?;
Ok(bytes)
}
fn body_failure(command: &str, limit: u64, error: ureq::Error) -> ClientError {
if matches!(error, ureq::Error::BodyExceedsLimit(_)) {
return ClientError::ResponseTooLarge {
command: command.to_owned(),
limit,
};
}
ClientError::Transport {
command: command.to_owned(),
source: Box::new(error),
}
}
fn routed_to(error: ClientError, base: &str) -> ClientError {
let at = format!(" at {}", authority_of(base));
match error {
ClientError::Transport { command, source } => ClientError::Transport {
command: command + &at,
source,
},
ClientError::Cluster {
command,
code,
message,
raw,
} => ClientError::Cluster {
command: command + &at,
code,
message,
raw,
},
ClientError::Http {
command,
status,
body,
} => ClientError::Http {
command: command + &at,
status,
body,
},
ClientError::Decode { command, reason } => ClientError::Decode {
command: command + &at,
reason,
},
error @ ClientError::ResponseTooLarge { .. } => error,
other => other,
}
}
fn is_local(base: &str) -> bool {
let host = host_of(base);
if let Ok(address) = host.parse::<std::net::IpAddr>() {
return address.is_loopback() || address.is_unspecified();
}
host.eq_ignore_ascii_case("localhost")
}
fn host_of(base: &str) -> &str {
let authority = base
.split_once("://")
.map_or(base, |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
match authority.strip_prefix('[') {
Some(literal) => literal.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
}
}
fn remaining(deadline: Option<Instant>, command: &str) -> Result<Option<Duration>> {
let Some(deadline) = deadline else {
return Ok(None);
};
match deadline.checked_duration_since(Instant::now()) {
Some(left) if !left.is_zero() => Ok(Some(left)),
_ => Err(ClientError::Transport {
command: command.to_owned(),
source: Box::new(ureq::Error::Timeout(ureq::Timeout::Global)),
}),
}
}
#[cfg_attr(not(feature = "tls"), allow(unused_variables))]
fn build_agent(timeout: Duration, named: Option<&Path>) -> (ureq::Agent, Option<String>) {
#[allow(unused_mut)]
let mut builder = ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.http_status_as_error(false)
.max_redirects(0);
#[allow(unused_mut)]
let mut refused = None;
#[cfg(feature = "tls")]
match root_certs(named) {
Ok(Some(tls)) => builder = builder.tls_config(tls),
Ok(None) => {}
Err(why) => refused = Some(why),
}
(builder.build().into(), refused)
}
#[cfg(feature = "tls")]
fn configured_bundle() -> Option<&'static Path> {
static NAMED: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
NAMED
.get_or_init(|| std::env::var_os(CA_BUNDLE).map(std::path::PathBuf::from))
.as_deref()
}
#[cfg(not(feature = "tls"))]
fn configured_bundle() -> Option<&'static Path> {
None
}
#[cfg(feature = "tls")]
fn root_certs(named: Option<&Path>) -> Result<Option<ureq::tls::TlsConfig>, String> {
static CONFIGURED: std::sync::OnceLock<Option<ureq::tls::TlsConfig>> =
std::sync::OnceLock::new();
if named == configured_bundle() {
if let Some(roots) = CONFIGURED.get() {
return Ok(roots.clone());
}
let roots = roots_for(named)?;
let _ = CONFIGURED.set(roots.clone());
return Ok(roots);
}
roots_for(named)
}
#[cfg(feature = "tls")]
fn roots_for(named: Option<&Path>) -> Result<Option<ureq::tls::TlsConfig>, String> {
match named {
Some(path) if !names_nothing(path) => bundle(path).map(Some),
_ => Ok(platform_roots()),
}
}
#[cfg(feature = "tls")]
fn names_nothing(path: &Path) -> bool {
path.to_str().is_some_and(|text| text.trim().is_empty())
}
#[cfg(feature = "tls")]
fn platform_roots() -> Option<ureq::tls::TlsConfig> {
#[cfg(feature = "platform-verifier")]
{
return Some(
ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build(),
);
}
#[allow(unreachable_code)]
None
}
#[cfg(feature = "tls")]
fn bundle(path: &Path) -> Result<ureq::tls::TlsConfig, String> {
use std::io::Read;
use ureq::tls::{Certificate, PemItem, RootCerts, TlsConfig, parse_pem};
let shown = path.display();
let found = std::fs::metadata(path)
.map_err(|e| format!("{CA_BUNDLE} names {shown}, which could not be read: {e}"))?;
if !found.is_file() {
return Err(format!(
"{CA_BUNDLE} names {shown}, which is not a regular file: a root bundle is read whole, \
and a directory or a pipe has no end to read to"
));
}
if found.len() > MAX_BUNDLE_BYTES {
return Err(format!(
"{CA_BUNDLE} names {shown}, which is {} bytes: a root bundle is a few hundred \
kilobytes and this reader stops at {MAX_BUNDLE_BYTES}",
found.len()
));
}
let mut pem = Vec::new();
std::fs::File::open(path)
.and_then(|file| file.take(MAX_BUNDLE_BYTES + 1).read_to_end(&mut pem))
.map_err(|e| format!("{CA_BUNDLE} names {shown}, which could not be read: {e}"))?;
if pem.len() as u64 > MAX_BUNDLE_BYTES {
return Err(format!(
"{CA_BUNDLE} names {shown}, which grew past {MAX_BUNDLE_BYTES} bytes while it was \
being read"
));
}
let mut certs: Vec<Certificate<'static>> = Vec::new();
let mut unparsable = 0usize;
let mut damaged: Option<String> = None;
for item in parse_pem(&pem) {
match item {
Ok(PemItem::Certificate(cert)) if is_x509(cert.der()) => certs.push(cert),
Ok(PemItem::Certificate(_)) => unparsable += 1,
Ok(_) => {}
Err(why) => {
damaged.get_or_insert_with(|| why.to_string());
}
}
}
if let Some(why) = damaged {
return Err(format!(
"{CA_BUNDLE} names {shown}, which holds a section that could not be read: {why}. A \
truncated download or a mangled copy-paste is the usual cause; the roots that did \
parse are deliberately not used, because a bundle that is quietly shorter than the \
file names is worse than one that is refused"
));
}
if unparsable > 0 {
return Err(format!(
"{CA_BUNDLE} names {shown}, where {unparsable} of {} -----BEGIN CERTIFICATE----- \
blocks hold something that is not an X.509 certificate. A PKCS#7 `.p7b` re-armoured \
under that label is the usual cause; `openssl pkcs7 -print_certs` converts one",
certs.len() + unparsable
));
}
if certs.is_empty() {
return Err(format!(
"{CA_BUNDLE} names {shown}, which holds no PEM certificates: expected at least one \
-----BEGIN CERTIFICATE----- block"
));
}
Ok(TlsConfig::builder()
.root_certs(RootCerts::new_with_certs(&certs))
.build())
}
#[cfg(feature = "tls")]
mod der {
pub(super) const INTEGER: u8 = 0x02;
pub(super) const BIT_STRING: u8 = 0x03;
pub(super) const SEQUENCE: u8 = 0x30;
pub(super) const VERSION: u8 = 0xa0;
}
#[cfg(feature = "tls")]
fn is_x509(der: &[u8]) -> bool {
let Some((body, after)) = expect(der, der::SEQUENCE) else {
return false;
};
if !after.is_empty() {
return false;
}
let Some((tbs, rest)) = expect(body, der::SEQUENCE) else {
return false;
};
let Some((_, rest)) = expect(rest, der::SEQUENCE) else {
return false;
};
let Some((_, rest)) = expect(rest, der::BIT_STRING) else {
return false;
};
rest.is_empty() && is_tbs_certificate(tbs)
}
#[cfg(feature = "tls")]
fn is_tbs_certificate(tbs: &[u8]) -> bool {
let after_version = match tlv(tbs) {
Some((tag, _, rest)) if tag == der::VERSION => rest,
_ => tbs,
};
let Some((_, mut rest)) = expect(after_version, der::INTEGER) else {
return false;
};
for _ in 0..5 {
let Some((_, next)) = expect(rest, der::SEQUENCE) else {
return false;
};
rest = next;
}
true
}
#[cfg(feature = "tls")]
fn expect(input: &[u8], tag: u8) -> Option<(&[u8], &[u8])> {
match tlv(input) {
Some((found, contents, rest)) if found == tag => Some((contents, rest)),
_ => None,
}
}
#[cfg(feature = "tls")]
fn tlv(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
let (&tag, rest) = input.split_first()?;
if tag & 0x1f == 0x1f {
return None;
}
let (&first, rest) = rest.split_first()?;
let (length, rest) = if first < 0x80 {
(usize::from(first), rest)
} else {
let count = usize::from(first & 0x7f);
if count == 0 || count > 4 {
return None;
}
let (bytes, rest) = rest.split_at_checked(count)?;
if bytes[0] == 0 || (count == 1 && bytes[0] < 0x80) {
return None;
}
let length = bytes
.iter()
.fold(0usize, |whole, byte| (whole << 8) | usize::from(*byte));
(length, rest)
};
let (contents, rest) = rest.split_at_checked(length)?;
Some((tag, contents, rest))
}
#[cfg(not(feature = "tls"))]
fn tls_unavailable(base: &str) -> Option<ClientError> {
base.starts_with("https://").then(|| {
ClientError::Config(format!(
"{base} needs TLS, and this build has none: the `tls` feature of \
ytsaurus-client is off. Enable it, or use an http:// proxy."
))
})
}
#[cfg(feature = "tls")]
fn tls_unavailable(_base: &str) -> Option<ClientError> {
None
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
Get,
Post,
Put,
}
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
fn resolve(request: &str, location: &str) -> Option<String> {
let location = location.trim();
if location.is_empty() {
return None;
}
if has_scheme(location) {
return Some(location.to_owned());
}
let (scheme, rest) = request.split_once("://")?;
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, target) = rest.split_at(end);
if authority.is_empty() {
return None;
}
if let Some(elsewhere) = location.strip_prefix("//") {
return Some(format!("{scheme}://{elsewhere}"));
}
if location.starts_with('/') {
return Some(format!("{scheme}://{authority}{location}"));
}
let base = target.split('#').next().unwrap_or("");
let path = base.split('?').next().unwrap_or("");
if location.starts_with('#') {
return Some(format!("{scheme}://{authority}{base}{location}"));
}
if location.starts_with('?') {
return Some(format!("{scheme}://{authority}{path}{location}"));
}
let directory = path.rsplit_once('/').map_or("", |(head, _)| head);
Some(format!("{scheme}://{authority}{directory}/{location}"))
}
fn has_scheme(url: &str) -> bool {
let Some(colon) = url.find(':') else {
return false;
};
let scheme = &url[..colon];
!scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn same_origin(one: &str, other: &str) -> bool {
match (origin(one), origin(other)) {
(Some(one), Some(other)) => one == other,
_ => false,
}
}
fn origin(url: &str) -> Option<(String, String, u16)> {
let (scheme, rest) = url.split_once("://")?;
let scheme = scheme.to_ascii_lowercase();
let port = match scheme.as_str() {
"http" => 80,
"https" => 443,
_ => return None,
};
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..end];
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let (host, port) = match host_port.rsplit_once(':') {
Some((host, given)) if !given.is_empty() && given.bytes().all(|b| b.is_ascii_digit()) => {
(host, given.parse().ok()?)
}
_ => (host_port, port),
};
if host.is_empty() {
return None;
}
Some((scheme, host.to_ascii_lowercase(), port))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::yson_build::map;
fn transport(transaction: Option<&str>) -> Transport {
let mut transport = Transport::new("http://localhost:8000", None, Duration::from_secs(1));
transport.set_transaction(transaction.map(str::to_owned));
transport
}
fn authenticated() -> Transport {
Transport::new(
"http://localhost:8000",
Some("secret-token".to_owned()),
Duration::from_secs(1),
)
}
fn rendered(value: &YsonValue) -> String {
to_string(value, YsonFormat::Text).expect("encodes")
}
#[test]
fn a_bound_client_puts_every_command_in_its_transaction() {
let params = map([("path", string("//tmp/out"))]);
let stamped = transport(Some("3-5d231-10001-db88"))
.in_transaction("write_table", ¶ms)
.expect("stamped");
assert_eq!(
rendered(&stamped),
r#"{path="//tmp/out";transaction_id="3-5d231-10001-db88"}"#
);
}
#[test]
fn an_unbound_client_leaves_the_parameters_alone() {
let params = map([("path", string("//tmp/out"))]);
assert!(transport(None).in_transaction("get", ¶ms).is_none());
}
#[test]
fn a_command_that_names_a_transaction_keeps_the_one_it_named() {
let params = map([("transaction_id", string("the-one-i-meant"))]);
assert!(
transport(Some("some-other-one"))
.in_transaction("commit_transaction", ¶ms)
.is_none()
);
}
#[test]
fn a_scheduler_command_is_not_put_in_a_transaction() {
let params = map([("operation_id", string("1-2-3-4"))]);
let bound = transport(Some("3-5d231-10001-db88"));
for command in [
"get_operation",
"list_jobs",
"get_job_stderr",
"abort_operation",
] {
assert!(
bound.in_transaction(command, ¶ms).is_none(),
"{command} was stamped with a transaction id"
);
}
}
#[cfg(feature = "tls")]
const CA_PEM: &str = "\
-----BEGIN CERTIFICATE-----
MIIDHTCCAgWgAwIBAgIUf6mwbBS7JGIyvPDkCpiBRHp914cwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTeXRzYXVydXMtcnMgdGVzdCBDQTAeFw0yNjA4MDYyMDM4
MTJaFw00NjA4MDEyMDM4MTJaMB4xHDAaBgNVBAMME3l0c2F1cnVzLXJzIHRlc3Qg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDqPTrcPPGiHlv4aV8v
AdrNtzvlhHciQbd7Pz0tLCmn8OGCjwt3Q/V22h6HSWijIleHPqn6bTSMYfPGAxRe
mAiqSsMLpM+GYWZAg8Kz7VSsK4f0s4dW6i82QYFVk/+04N/0RUJ3A9RTloxSl8+a
HT5MF2x4LGr1eBgpz4UEsC5cJtkzA8OCM2a2TtNiuo/PtKzZx2TuvEk+Ub5Gn/lt
tZn8m9z6o8n51D3vEIfHfXPyFre2+cz+Ao680kc0KP8PWlG89mhvMZ2VYGJG2T/Z
6Ddpj7aXM+jKCCjBTLMkLYaIuNO9//72kmBYsVgaBAMNYMBaBqQX1TOjwxbiBbv5
fbJnAgMBAAGjUzBRMB0GA1UdDgQWBBSniLAZD6er7hHpwg12hIX57PHb2TAfBgNV
HSMEGDAWgBSniLAZD6er7hHpwg12hIX57PHb2TAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQBsR5VKflwEwRTNY1dobAWKS6kLTszpRFlQN2qBMTv+
NhS0i7mrNUzKadZkmlQuOMIhZl6gR4mB0XVPgkJKJ+ch8SfuaBW3Po4dTdrKfB6K
CgCTM54UB3QQAlAjpVhLCS7aCT8hgKEX1+1OD1SmBNQ/Jj9OOoKxVkq9prjSzILW
pXeT/OKKRqZ7tjG2jh55XPgE+GWLCfo3VsPqcleAoxQEWATryTF4fwKI9tuAgJ8p
pN1M6UxJFatwx23InC/jVPR6wBu5h1SyCjIxuW/j8pgriTm8wR3XaTly49j6VQDH
8KGhyM+0UsZEWeI05Uq9c/Vs5TlJAcnvwJwxJqREhlHY
-----END CERTIFICATE-----
";
#[cfg(feature = "tls")]
const KEY_PEM: &str = "\
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgt4eMMaSBwIKAgwrT
zzKo64LyF0YMvm3I61+EK3DDRDmhRANCAAS3XrEb3d5QdjQGGuAny4phX9xstUpp
B7b7J0xB2R7nPBn3+4PRz/35FJrHFmNkKD47D6ZMldYk7ykxNLNBGzIU
-----END PRIVATE KEY-----
";
#[cfg(feature = "tls")]
const REARMOURED_P7B: &str = "\
-----BEGIN CERTIFICATE-----
MIIDTAYJKoZIhvcNAQcCoIIDPTCCAzkCAQExADALBgkqhkiG9w0BBwGgggMhMIID
HTCCAgWgAwIBAgIUf6mwbBS7JGIyvPDkCpiBRHp914cwDQYJKoZIhvcNAQELBQAw
HjEcMBoGA1UEAwwTeXRzYXVydXMtcnMgdGVzdCBDQTAeFw0yNjA4MDYyMDM4MTJa
Fw00NjA4MDEyMDM4MTJaMB4xHDAaBgNVBAMME3l0c2F1cnVzLXJzIHRlc3QgQ0Ew
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDqPTrcPPGiHlv4aV8vAdrN
tzvlhHciQbd7Pz0tLCmn8OGCjwt3Q/V22h6HSWijIleHPqn6bTSMYfPGAxRemAiq
SsMLpM+GYWZAg8Kz7VSsK4f0s4dW6i82QYFVk/+04N/0RUJ3A9RTloxSl8+aHT5M
F2x4LGr1eBgpz4UEsC5cJtkzA8OCM2a2TtNiuo/PtKzZx2TuvEk+Ub5Gn/lttZn8
m9z6o8n51D3vEIfHfXPyFre2+cz+Ao680kc0KP8PWlG89mhvMZ2VYGJG2T/Z6Ddp
j7aXM+jKCCjBTLMkLYaIuNO9//72kmBYsVgaBAMNYMBaBqQX1TOjwxbiBbv5fbJn
AgMBAAGjUzBRMB0GA1UdDgQWBBSniLAZD6er7hHpwg12hIX57PHb2TAfBgNVHSME
GDAWgBSniLAZD6er7hHpwg12hIX57PHb2TAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQBsR5VKflwEwRTNY1dobAWKS6kLTszpRFlQN2qBMTv+NhS0
i7mrNUzKadZkmlQuOMIhZl6gR4mB0XVPgkJKJ+ch8SfuaBW3Po4dTdrKfB6KCgCT
M54UB3QQAlAjpVhLCS7aCT8hgKEX1+1OD1SmBNQ/Jj9OOoKxVkq9prjSzILWpXeT
/OKKRqZ7tjG2jh55XPgE+GWLCfo3VsPqcleAoxQEWATryTF4fwKI9tuAgJ8ppN1M
6UxJFatwx23InC/jVPR6wBu5h1SyCjIxuW/j8pgriTm8wR3XaTly49j6VQDH8KGh
yM+0UsZEWeI05Uq9c/Vs5TlJAcnvwJwxJqREhlHYMQA=
-----END CERTIFICATE-----
";
#[cfg(feature = "tls")]
struct TempPem(std::path::PathBuf);
#[cfg(feature = "tls")]
impl TempPem {
fn new(contents: &str) -> Self {
let path = std::env::temp_dir()
.join(format!("ytsaurus-rs-ca-{:x}.pem", crate::unique::word(0)));
std::fs::write(&path, contents).expect("writes the bundle");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn shown(&self) -> String {
self.0.display().to_string()
}
}
#[cfg(feature = "tls")]
impl Drop for TempPem {
fn drop(&mut self) {
std::fs::remove_file(&self.0).ok();
}
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_becomes_the_roots_and_its_private_key_is_left_alone() {
let file = TempPem::new(&format!("{CA_PEM}{KEY_PEM}{CA_PEM}"));
let config = bundle(file.path()).expect("a bundle with certificates in it");
match config.root_certs() {
ureq::tls::RootCerts::Specific(certs) => assert_eq!(certs.len(), 2),
other => panic!("the bundle did not become the roots: {other:?}"),
}
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_that_parses_to_nothing_is_refused() {
for (what, contents) in [
("a key and no certificate", KEY_PEM),
("an empty file", ""),
("the cluster's HTML login page", "<html>Sign in</html>\n"),
] {
let file = TempPem::new(contents);
let refusal = bundle(file.path()).expect_err(what);
assert!(refusal.contains(CA_BUNDLE), "{what}: {refusal}");
assert!(refusal.contains(&file.shown()), "{what}: {refusal}");
assert!(refusal.contains("no PEM certificates"), "{what}: {refusal}");
}
}
#[test]
#[cfg(feature = "tls")]
fn a_pkcs7_bundle_wearing_a_certificate_label_is_refused() {
let file = TempPem::new(REARMOURED_P7B);
let refusal = bundle(file.path()).expect_err("a PKCS#7 blob is not a certificate");
assert!(refusal.contains(CA_BUNDLE), "{refusal}");
assert!(refusal.contains(&file.shown()), "{refusal}");
assert!(refusal.contains("not an X.509 certificate"), "{refusal}");
assert!(refusal.contains("PKCS#7"), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn one_good_certificate_does_not_excuse_the_rest_of_the_file() {
let file = TempPem::new(&format!("{CA_PEM}{REARMOURED_P7B}{REARMOURED_P7B}"));
let refusal = bundle(file.path()).expect_err("two blocks are not certificates");
assert!(refusal.contains("2 of 3"), "{refusal}");
assert!(refusal.contains(&file.shown()), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn a_block_that_did_not_survive_the_envelope_refuses_the_file_too() {
for (what, body) in [
(
"corrupt base64",
format!(
"{CA_PEM}-----BEGIN CERTIFICATE-----\n!!!! not base64 !!!!\n\
-----END CERTIFICATE-----\n{CA_PEM}"
),
),
(
"a file that stops mid-block",
format!("{CA_PEM}-----BEGIN CERTIFICATE-----\nMIIB"),
),
] {
let file = TempPem::new(&body);
let refusal = bundle(file.path()).err().unwrap_or_else(|| {
panic!("{what} should refuse the file rather than shorten the store")
});
assert!(refusal.contains(&file.shown()), "{what}: {refusal}");
assert!(refusal.contains("could not be read"), "{what}: {refusal}");
}
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_larger_than_any_bundle_is_refused_rather_than_held() {
let file = TempPem::new("");
std::fs::OpenOptions::new()
.write(true)
.open(file.path())
.and_then(|f| f.set_len(MAX_BUNDLE_BYTES + 1))
.expect("sizes the file");
let refusal = bundle(file.path()).expect_err("larger than any root bundle");
assert!(refusal.contains(CA_BUNDLE), "{refusal}");
assert!(refusal.contains(&file.shown()), "{refusal}");
assert!(refusal.contains("a few hundred kilobytes"), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_that_is_not_a_regular_file_is_refused_rather_than_read() {
let refusal = bundle(&std::env::temp_dir()).expect_err("a directory is not a bundle");
assert!(refusal.contains(CA_BUNDLE), "{refusal}");
assert!(refusal.contains("not a regular file"), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_beats_whatever_the_build_would_have_trusted() {
let file = TempPem::new(CA_PEM);
let chosen = roots_for(Some(file.path()))
.expect("a readable bundle")
.expect("some roots");
assert!(
matches!(chosen.root_certs(), ureq::tls::RootCerts::Specific(_)),
"{:?}",
chosen.root_certs()
);
}
fn routed(configured: &str, host: &str) -> Option<String> {
heavy_base(configured, host, &HeavyHosts::SameDomain).ok()
}
fn routed_anywhere(configured: &str, host: &str) -> Option<String> {
heavy_base(configured, host, &HeavyHosts::Anywhere).ok()
}
#[test]
fn a_host_from_the_cluster_keeps_the_scheme_it_was_reached_by() {
assert_eq!(
routed("https://cluster.example.net", "n0132-sas.example.net"),
Some("https://n0132-sas.example.net".to_owned())
);
assert_eq!(
routed("http://cluster.example.net", "n0132-sas.example.net"),
Some("http://n0132-sas.example.net".to_owned())
);
assert_eq!(
routed(
"http://cluster.example.net:8000",
"n0132-sas.example.net:9013"
),
Some("http://n0132-sas.example.net:9013".to_owned())
);
assert_eq!(
routed("http://cluster.example.net:8000", "n0132-sas.example.net"),
Some("http://n0132-sas.example.net:8000".to_owned())
);
assert_eq!(
routed("https://cluster.example.net:8443", "n0132-sas.example.net"),
Some("https://n0132-sas.example.net:8443".to_owned())
);
}
#[test]
#[cfg(feature = "tls")]
fn a_named_bundle_that_will_not_parse_refuses_the_choice_itself() {
let file = TempPem::new(KEY_PEM);
let refusal = roots_for(Some(file.path())).expect_err("a key is not a root");
assert!(refusal.contains(CA_BUNDLE), "{refusal}");
assert!(refusal.contains(&file.shown()), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn a_variable_that_names_nothing_is_not_a_bundle() {
for named in [None, Some(Path::new("")), Some(Path::new(" "))] {
let chosen = roots_for(named).expect("no bundle was named");
let roots = chosen.as_ref().map(ureq::tls::TlsConfig::root_certs);
#[cfg(feature = "platform-verifier")]
assert!(
matches!(roots, Some(ureq::tls::RootCerts::PlatformVerifier)),
"{roots:?}"
);
#[cfg(not(feature = "platform-verifier"))]
assert!(roots.is_none(), "{roots:?}");
}
}
#[test]
fn a_hosts_answer_cannot_send_the_token_somewhere_else() {
assert_eq!(routed("https://cluster.example.net", "http://n0132"), None);
assert_eq!(
routed("https://cluster.example.net", "https://n0132.example.net"),
None,
"a name that spells its own scheme is not a name"
);
assert_eq!(
routed(
"https://cluster.example.net",
"real.example.net@evil.example.net"
),
None
);
for shape in [
"n0132.example.net/../../evil",
"n0132.example.net/api",
"n0132.example.net?x=1",
"n0132.example.net#f",
"n0132 .example.net",
"n0132.example.net\tn0133.example.net",
"",
" ",
] {
assert_eq!(
routed("https://cluster.example.net", shape),
None,
"{shape:?} was accepted as a host name"
);
}
assert_eq!(
routed("https://cluster.example.net", " \tn0132.example.net\n"),
Some("https://n0132.example.net".to_owned())
);
for elsewhere in [
"n0132-sas.somewhere-else.net",
"cluster.example.net.evil.com",
"evil.com",
"notexample.net",
] {
assert_eq!(
routed("https://cluster.example.net", elsewhere),
None,
"{elsewhere} was followed"
);
}
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_that_cannot_be_read_is_refused_rather_than_ignored() {
let missing = std::env::temp_dir().join("ytsaurus-rs-no-such-bundle.pem");
let refusal = bundle(&missing).expect_err("nothing to read");
assert!(refusal.contains(CA_BUNDLE), "{refusal}");
assert!(refusal.contains("could not be read"), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn the_variable_is_spelled_the_way_the_documentation_spells_it() {
assert_eq!(CA_BUNDLE, "YT_CA_BUNDLE");
let missing = std::env::temp_dir().join("ytsaurus-rs-no-such-bundle.pem");
let refusal = bundle(&missing).expect_err("nothing to read");
assert!(refusal.contains("YT_CA_BUNDLE"), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn a_named_bundle_reaches_the_agent_that_is_built_from_it() {
let file = TempPem::new(CA_PEM);
let (agent, refused) = build_agent(Duration::from_secs(1), Some(file.path()));
assert!(refused.is_none(), "{refused:?}");
assert!(
matches!(
agent.config().tls_config().root_certs(),
ureq::tls::RootCerts::Specific(_)
),
"{:?}",
agent.config().tls_config().root_certs()
);
}
#[test]
fn the_domain_a_discovered_host_has_to_share() {
assert!(same_domain("cluster.example.net", "cluster.example.net"));
assert!(same_domain("cluster.example.net", "n0132-sas.example.net"));
assert!(same_domain(
"cluster.example.net",
"n0132-sas.cluster.example.net"
));
assert!(same_domain("cluster.example.net", "example.net"));
assert!(same_domain("Cluster.Example.NET", "n0132-sas.example.net"));
assert!(!same_domain("example.net", "n0132-sas.other.net"));
assert!(same_domain("example.net", "n0132-sas.example.net"));
assert!(same_domain("10.0.0.7", "10.0.0.7"));
assert!(!same_domain("10.0.0.7", "10.0.0.8"));
assert!(!same_domain("10.0.0.7", "n0132-sas.example.net"));
assert!(!same_domain("cluster.example.net", "10.0.0.7"));
assert!(!same_domain("cluster.example.net", "evil-example.net"));
assert!(!same_domain("cluster.example.net", "example.net.evil.com"));
}
#[test]
fn a_bare_cluster_name_is_matched_as_a_label_and_not_as_a_domain() {
assert!(same_domain("hume", "n0008-sas.hume.yt.example.net"));
assert!(same_domain("cluster-name", "n0008-sas.cluster-name"));
assert!(same_domain(
"yt-http-proxy",
"yt-http-proxy-0.yt-http-proxy.yt.svc.cluster.local"
));
assert!(!same_domain("hume", "hume.evil.com"));
assert!(!same_domain("hume", "n0008-sas.humeier.yt.example.net"));
assert!(!same_domain("hume", "evil.com"));
assert!(same_domain("hume", "hume"));
assert_eq!(
routed("https://hume", "n0008-sas.hume.yt.example.net"),
Some("https://n0008-sas.hume.yt.example.net".to_owned())
);
}
#[test]
#[cfg(feature = "tls")]
fn a_bundle_the_agent_could_not_honour_is_carried_out_of_the_constructor() {
let file = TempPem::new(KEY_PEM);
let (_, refused) = build_agent(Duration::from_secs(1), Some(file.path()));
let refusal = refused.expect("the refusal reaches the transport");
assert!(refusal.contains(CA_BUNDLE), "{refusal}");
assert!(refusal.contains(&file.shown()), "{refusal}");
}
#[test]
#[cfg(feature = "tls")]
fn the_der_check_takes_certificates_and_leaves_everything_else() {
use ureq::tls::{Certificate, PemItem, parse_pem};
let der = |pem: &str| {
parse_pem(pem.as_bytes())
.find_map(|item| match item {
Ok(PemItem::Certificate(cert)) => Some(Certificate::to_owned(&cert)),
_ => None,
})
.expect("one CERTIFICATE block")
};
assert!(is_x509(der(CA_PEM).der()));
assert!(!is_x509(der(REARMOURED_P7B).der()));
let good = der(CA_PEM);
assert!(!is_x509(&[]));
assert!(!is_x509(&good.der()[..good.der().len() - 1]));
assert!(!is_x509(&[good.der(), b"\x00"].concat()));
}
#[test]
#[cfg(feature = "tls")]
fn a_refused_bundle_is_reported_instead_of_the_first_request() {
let mut transport =
Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
transport.tls_refused = Some("YT_CA_BUNDLE names /etc/no-such-file".to_owned());
let error = transport.unusable(&transport.base).expect("a refusal");
assert!(matches!(error, ClientError::Config(_)), "{error}");
assert!(
transport
.unusable("https://n0132-sas.example.net")
.is_some()
);
assert!(transport.unusable("http://n0132-sas.example.net").is_none());
assert!(error.to_string().contains("YT_CA_BUNDLE"), "{error}");
}
#[test]
fn a_refused_bundle_does_not_stop_a_cluster_reached_over_plain_http() {
let mut transport = transport(None);
transport.tls_refused = Some("YT_CA_BUNDLE names /etc/no-such-file".to_owned());
assert!(transport.unusable(&transport.base).is_none());
}
fn cannot_send() -> Transport {
let mut transport = Transport::new("https://127.0.0.1:1", None, Duration::from_millis(250));
transport.set_retries(RetryPolicy::none().quiet());
#[cfg(feature = "tls")]
{
transport.tls_refused = Some(format!("{CA_BUNDLE} names /etc/no-such-file"));
}
transport
}
#[test]
fn a_command_is_refused_before_a_socket_is_opened() {
let transport = cannot_send();
let error = transport
.dispatch(
&transport.base,
Method::Get,
"get_supported_features",
&map::<&str>([]),
Outgoing::Empty,
false,
)
.expect_err("a transport that cannot be used");
assert!(matches!(error, ClientError::Config(_)), "{error}");
}
#[test]
fn the_hosts_lookup_is_refused_before_a_socket_is_opened() {
let error = cannot_send()
.fetch("/hosts", "hosts")
.expect_err("a transport that cannot be used");
assert!(matches!(error, ClientError::Config(_)), "{error}");
}
#[test]
fn an_installation_that_really_does_answer_elsewhere_can_say_so() {
assert_eq!(
routed_anywhere(
"https://cluster.example.net",
"n0132-sas.somewhere-else.net"
),
Some("https://n0132-sas.somewhere-else.net".to_owned())
);
assert_eq!(
routed_anywhere("https://cluster.example.net", "http://n0132"),
None,
"the escape hatch is about the domain, not about the scheme"
);
assert_eq!(
routed_anywhere(
"https://cluster.example.net",
"real.example.net@evil.example.net"
),
None
);
for blank in ["", " ", "\t\n"] {
assert_eq!(
routed_anywhere("https://cluster.example.net:8000", blank),
None,
"{blank:?} was accepted as a host name"
);
}
}
#[test]
fn a_list_written_out_by_hand_is_the_third_answer() {
let only = HeavyHosts::Only(vec![
"n0132-sas.somewhere-else.net".to_owned(),
"n0133-sas.somewhere-else.net:9013".to_owned(),
]);
assert_eq!(
heavy_base(
"https://cluster.example.net:8443",
"n0132-sas.somewhere-else.net",
&only
),
Ok("https://n0132-sas.somewhere-else.net:8443".to_owned()),
"a listed name outside the domain is still allowed"
);
assert_eq!(
heavy_base(
"https://cluster.example.net:8443",
"N0133-SAS.somewhere-else.net:9013",
&only
),
Ok("https://N0133-SAS.somewhere-else.net:9013".to_owned()),
);
assert_eq!(
heavy_base(
"https://cluster.example.net:8443",
"n0133-sas.somewhere-else.net",
&only
),
Ok("https://n0133-sas.somewhere-else.net:8443".to_owned()),
"a listed port must not be a requirement on an answer that has none"
);
assert_eq!(
heavy_base(
"https://cluster.example.net:8443",
"n0133-sas.somewhere-else.net:9014",
&only
),
Err(Declined::Elsewhere),
"a port both sides name has to be the same port"
);
assert_eq!(
heavy_base(
"https://cluster.example.net",
"n0134-sas.example.net",
&only
),
Err(Declined::Elsewhere)
);
assert_eq!(
heavy_base("https://cluster.example.net", "http://n0132", &only),
Err(Declined::Malformed),
"a list is about which names, not about what a name may look like"
);
assert_eq!(
heavy_base(
"https://cluster.example.net",
"n0132-sas.example.net",
&HeavyHosts::Only(Vec::new())
),
Err(Declined::Elsewhere)
);
}
#[test]
fn a_named_domain_widens_the_rule_without_removing_it() {
let under = HeavyHosts::Under {
domains: vec!["proxy-zone.net".to_owned()],
ignored: Vec::new(),
};
let configured = "https://cluster.example.net";
assert_eq!(
heavy_base(configured, "n0132-sas.rack7.proxy-zone.net", &under),
Ok("https://n0132-sas.rack7.proxy-zone.net".to_owned())
);
assert_eq!(
heavy_base(configured, "N0133-SAS.rack7.PROXY-ZONE.net", &under),
Ok("https://N0133-SAS.rack7.PROXY-ZONE.net".to_owned())
);
assert_eq!(
heavy_base(configured, "proxy-zone.net", &under),
Ok("https://proxy-zone.net".to_owned())
);
assert_eq!(
heavy_base(configured, "n0008-sas.example.net", &under),
Ok("https://n0008-sas.example.net".to_owned())
);
for elsewhere in [
"proxy-zone.net.evil.com",
"evil-proxy-zone.net",
"n0132-sas.somewhere-else.net",
] {
assert_eq!(
heavy_base(configured, elsewhere, &under),
Err(Declined::Elsewhere),
"{elsewhere}"
);
}
assert_eq!(
heavy_base(configured, "http://n0132-sas.rack7.proxy-zone.net", &under),
Err(Declined::Malformed)
);
assert_eq!(
heavy_base(
configured,
"n0132-sas.rack7.proxy-zone.net",
&HeavyHosts::Under {
domains: Vec::new(),
ignored: Vec::new(),
}
),
Err(Declined::Elsewhere)
);
}
#[test]
fn a_refusal_names_the_domains_that_were_added() {
let under = HeavyHosts::Under {
domains: vec!["proxy-zone.net".to_owned()],
ignored: Vec::new(),
};
let because = Declined::Elsewhere.because(&under, "https://cluster.example.net");
assert!(because.contains("cluster.example.net"), "{because}");
assert!(because.contains("proxy-zone.net"), "{because}");
assert_eq!(
Declined::Elsewhere.because(
&HeavyHosts::Under {
domains: Vec::new(),
ignored: Vec::new(),
},
"https://cluster.example.net"
),
Declined::Elsewhere.because(&HeavyHosts::SameDomain, "https://cluster.example.net")
);
}
#[test]
fn a_written_domain_is_normalised_the_way_it_gets_written() {
let mut transport = Transport::new("https://cluster.example.net", None, HOSTS_TIMEOUT);
transport.set_heavy_proxies_under(vec![
" .Proxy-Zone.net. ".to_owned(),
"*.proxy-zone.net".to_owned(),
"https://proxy-zone.net".to_owned(),
"proxy-zone.net:443".to_owned(),
"https://proxy-zone.net./".to_owned(),
"proxy-zone.net.:443".to_owned(),
]);
assert_eq!(
transport.heavy_hosts_debug(),
r#"Under { domains: ["proxy-zone.net"], ignored: [] }"#
);
assert_eq!(
heavy_base(
&transport.base,
"n0132-sas.rack7.proxy-zone.net",
&transport.hosts
),
Ok("https://n0132-sas.rack7.proxy-zone.net".to_owned()),
);
}
#[test]
fn an_entry_that_is_not_a_domain_is_dropped_rather_than_believed() {
let mut transport = Transport::new("https://cluster.example.net", None, HOSTS_TIMEOUT);
transport.set_heavy_proxies_under(vec![
"net".to_owned(),
" ".to_owned(),
String::new(),
".".to_owned(),
"*".to_owned(),
]);
assert_eq!(
transport.heavy_hosts_debug(),
r#"Under { domains: [], ignored: ["net"] }"#
);
let because = Declined::Elsewhere.because(&transport.hosts, &transport.base);
assert!(because.contains("ignored, not a domain: net"), "{because}");
assert_eq!(
heavy_base(&transport.base, "n0132-sas.example.net", &transport.hosts),
Ok("https://n0132-sas.example.net".to_owned())
);
assert_eq!(
heavy_base(
&transport.base,
"n0132-sas.rack7.proxy-zone.net",
&transport.hosts
),
Err(Declined::Elsewhere)
);
}
#[test]
fn an_added_domain_carries_the_configured_port_and_keeps_a_named_one() {
let under = HeavyHosts::Under {
domains: vec!["proxy-zone.net".to_owned()],
ignored: Vec::new(),
};
assert_eq!(
heavy_base(
"https://cluster.example.net:8443",
"n0132-sas.rack7.proxy-zone.net",
&under
),
Ok("https://n0132-sas.rack7.proxy-zone.net:8443".to_owned())
);
assert_eq!(
heavy_base(
"https://cluster.example.net:8443",
"n0132-sas.rack7.proxy-zone.net:9013",
&under
),
Ok("https://n0132-sas.rack7.proxy-zone.net:9013".to_owned())
);
}
#[test]
fn a_dotless_configured_name_keeps_its_label_rule_when_a_domain_is_added() {
let under = HeavyHosts::Under {
domains: vec!["proxy-zone.net".to_owned()],
ignored: Vec::new(),
};
assert_eq!(
heavy_base("https://hume", "n0008-sas.hume.yt.example.net", &under),
Ok("https://n0008-sas.hume.yt.example.net".to_owned()),
"the label rule still applies"
);
assert_eq!(
heavy_base("https://hume", "n0132-sas.rack7.proxy-zone.net", &under),
Ok("https://n0132-sas.rack7.proxy-zone.net".to_owned()),
"and the added domain applies beside it"
);
assert_eq!(
heavy_base("https://hume", "hume.evil.com", &under),
Err(Declined::Elsewhere),
"and neither admits the cluster's name in a host's position"
);
}
#[test]
fn a_bracketed_address_has_to_hold_an_ipv6_literal() {
assert_eq!(
routed_anywhere("http://[2a02:6b8::1]:8000", "[2a02:6b8::2]:9013"),
Some("http://[2a02:6b8::2]:9013".to_owned())
);
assert_eq!(
routed_anywhere("http://[2a02:6b8::1]:8000", "[2a02:6b8::2]"),
Some("http://[2a02:6b8::2]:8000".to_owned()),
"the configured port carries through a bracketed name too"
);
assert_eq!(
routed_anywhere("http://cluster.example.net", "2a02:6b8::2"),
None
);
assert_eq!(
routed_anywhere("http://cluster.example.net", "n0132:9013:9014"),
None
);
for shape in [
"[n0132.example.com]evil.attacker.com",
"[n0132.example.com]",
"[n0132.example.com]:9013",
"[2a02:6b8::2]junk",
"[2a02:6b8::2]:junk",
"[2a02:6b8::2]:",
"[2a02:6b8::2",
"[]",
"[]:9013",
"n0132.example.net:",
"n0132.example.net:90a3",
":9013",
] {
assert_eq!(
routed_anywhere("http://cluster.example.net", shape),
None,
"{shape:?} was accepted as a host name"
);
}
}
#[test]
fn a_routed_failure_names_the_host_it_went_to() {
let failed = routed_to(
ClientError::Http {
command: "write_table".to_owned(),
status: 502,
body: String::new(),
},
"https://n0132-sas.example.net:9013",
);
assert!(
failed
.to_string()
.starts_with("write_table at n0132-sas.example.net:9013:"),
"{failed}"
);
let local = routed_to(
ClientError::Config("no proxy".to_owned()),
"https://n0132-sas.example.net:9013",
);
assert_eq!(local.to_string(), "no proxy");
}
#[test]
fn a_cluster_on_loopback_is_not_asked_where_its_heavy_proxies_are() {
for local in [
"http://localhost:8000",
"http://LOCALHOST",
"http://127.0.0.1:8000",
"http://127.99.1.4",
"https://[::1]:443",
"http://0.0.0.0:8000",
] {
assert!(is_local(local), "{local}");
}
for remote in [
"https://cluster.example.net",
"http://cluster.example.net:8000",
"https://10.0.0.7",
"https://[2a02:6b8::1]:443",
"https://localhost.example.net",
] {
assert!(!is_local(remote), "{remote}");
}
}
#[test]
fn the_host_is_read_out_of_the_address_without_its_furniture() {
assert_eq!(
host_of("https://cluster.example.net/"),
"cluster.example.net"
);
assert_eq!(
host_of("http://cluster.example.net:8000"),
"cluster.example.net"
);
assert_eq!(host_of("cluster.example.net:8000"), "cluster.example.net");
assert_eq!(host_of("http://[2a02:6b8::1]:8000"), "2a02:6b8::1");
assert_eq!(
host_of("http://user:pass@cluster.example.net"),
"cluster.example.net"
);
}
#[test]
fn only_a_heavy_command_asks_where_to_go() {
let transport = Transport::new(
"http://cluster.invalid:8000",
None,
Duration::from_millis(50),
);
for light in [
Repeatable::Freely,
Repeatable::WithMutationId,
Repeatable::Never,
] {
let destination = transport.base_for(light);
assert!(
matches!(destination, Destination::Configured(_)),
"{light:?} went looking for a heavy proxy"
);
assert_eq!(destination.address(), "http://cluster.invalid:8000");
}
}
fn pooled(transport: &Transport, hosts: &[&str]) {
*lock(&transport.heavy) = HeavyProxy::Pool(HeavyPool {
hosts: hosts.iter().map(|host| (*host).to_owned()).collect(),
fetched: Instant::now(),
});
}
fn discovered(base: &str) -> Destination<'static> {
Destination::Discovered(base.to_owned())
}
fn pool_of(transport: &Transport) -> Option<Vec<String>> {
match &*lock(&transport.heavy) {
HeavyProxy::Pool(pool) => Some(pool.hosts.clone()),
_ => None,
}
}
#[test]
fn a_rejected_certificate_drops_the_host_and_a_wrong_command_does_not() {
let mut transport =
Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
transport.set_proxy_discovery(true);
pooled(
&transport,
&[
"https://n0132-sas.example.net",
"https://n0133-sas.example.net",
],
);
let rejected: Result<()> = Err(ClientError::Transport {
command: "write_table".to_owned(),
source: Box::new(ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"invalid peer certificate: certificate not valid for name \
\"n0132-sas.example.net\"; certificate is only valid for [\"cluster.example.net\"]",
))),
});
let reported = transport.after_heavy(
Repeatable::Heavy,
&discovered("https://n0132-sas.example.net"),
rejected,
);
assert!(reported.is_err());
assert_eq!(
pool_of(&transport).as_deref(),
Some(&["https://n0133-sas.example.net".to_owned()][..]),
"the host whose certificate was rejected stayed in the pool"
);
let wrong_command: Result<()> = Err(ClientError::Http {
command: "write_table".to_owned(),
status: 404,
body: String::new(),
});
let reported = transport.after_heavy(
Repeatable::Heavy,
&discovered("https://n0133-sas.example.net"),
wrong_command,
);
assert!(reported.is_err());
assert_eq!(
pool_of(&transport).as_deref(),
Some(&["https://n0133-sas.example.net".to_owned()][..]),
"a mistaken command evicted a perfectly good host"
);
}
#[test]
fn a_response_too_large_leaves_the_host_that_served_it_in_the_pool() {
let mut transport =
Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
transport.set_proxy_discovery(true);
let both = [
"https://n0132-sas.example.net".to_owned(),
"https://n0133-sas.example.net".to_owned(),
];
let seed = |transport: &Transport| {
pooled(
transport,
&[
"https://n0132-sas.example.net",
"https://n0133-sas.example.net",
],
);
};
seed(&transport);
let as_it_was: Result<()> = Err(ClientError::Transport {
command: "read_file".to_owned(),
source: Box::new(ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT)),
});
let _ = transport.after_heavy(
Repeatable::Heavy,
&discovered("https://n0132-sas.example.net"),
as_it_was,
);
assert_eq!(
pool_of(&transport).as_deref(),
Some(&["https://n0133-sas.example.net".to_owned()][..]),
"the old shape was supposed to evict the host — if it no longer \
does, the half of this test that follows has stopped proving \
anything"
);
seed(&transport);
let now: Result<()> = Err(body_failure(
"read_file",
RESPONSE_LIMIT,
ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
));
let reported = transport.after_heavy(
Repeatable::Heavy,
&discovered("https://n0132-sas.example.net"),
now,
);
assert!(reported.is_err());
assert_eq!(
pool_of(&transport).as_deref(),
Some(&both[..]),
"a response too large to hold cost the pool a healthy data proxy"
);
}
#[test]
fn a_response_too_large_keeps_the_way_past_it() {
let reported = routed_to(
body_failure(
"read_file",
RESPONSE_LIMIT,
ureq::Error::BodyExceedsLimit(0),
),
"https://n0132-sas.example.net",
);
let message = reported.to_string();
assert!(message.contains("read_file_streaming"), "{message}");
assert!(!message.contains(" at n0132-sas"), "{message}");
let neighbour = routed_to(
ClientError::Decode {
command: "read_file".to_owned(),
reason: "cut short".to_owned(),
},
"https://n0132-sas.example.net",
);
assert!(
neighbour.to_string().contains(" at n0132-sas"),
"{neighbour}"
);
}
#[test]
fn a_pool_with_nobody_left_falls_back() {
let mut transport =
Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
transport.set_proxy_discovery(true);
pooled(&transport, &["https://n0132-sas.example.net"]);
let refused: Result<()> = Err(ClientError::Http {
command: "write_table".to_owned(),
status: 503,
body: String::new(),
});
let _ = transport.after_heavy(
Repeatable::Heavy,
&discovered("https://n0132-sas.example.net"),
refused,
);
assert!(
matches!(&*lock(&transport.heavy), HeavyProxy::FellBack { .. }),
"an emptied pool did not fall back"
);
}
#[test]
fn a_discovered_host_that_spells_the_configured_address_is_still_dropped() {
let mut transport = Transport::new(
"https://n0132-sas.example.net",
None,
Duration::from_secs(1),
);
transport.set_proxy_discovery(true);
pooled(
&transport,
&[
"https://n0132-sas.example.net",
"https://n0133-sas.example.net",
],
);
let drained: Result<()> = Err(ClientError::Http {
command: "write_table".to_owned(),
status: 503,
body: String::new(),
});
let reported = transport.after_heavy(
Repeatable::Heavy,
&discovered("https://n0132-sas.example.net"),
drained,
);
assert!(
reported
.expect_err("a 503 is a failure")
.to_string()
.starts_with("write_table at n0132-sas.example.net:"),
"a routed failure at the configured host's own name went unattributed"
);
assert_eq!(
pool_of(&transport).as_deref(),
Some(&["https://n0133-sas.example.net".to_owned()][..]),
"the host was spared the drop for spelling the configured address"
);
}
#[test]
fn starting_an_operation_still_joins_the_transaction() {
let params = map([("operation_type", string("map"))]);
assert!(
transport(Some("3-5d231-10001-db88"))
.in_transaction("start_operation", ¶ms)
.is_some()
);
}
#[test]
fn ureq_follows_no_redirect_for_any_transport() {
assert_eq!(authenticated().agent.config().max_redirects(), 0);
assert_eq!(transport(None).agent.config().max_redirects(), 0);
}
#[test]
fn changing_the_timeout_keeps_the_redirect_policy() {
let mut transport = authenticated();
transport.set_timeout(Duration::from_secs(30));
assert_eq!(transport.agent.config().max_redirects(), 0);
assert_eq!(
transport.agent.config().timeouts().global,
Some(Duration::from_secs(30))
);
}
#[test]
fn a_location_is_resolved_against_the_address_it_came_from() {
let request = "http://proxy.example.net:8000/api/v4/exists?path=//tmp";
assert_eq!(
resolve(request, "https://data.example.net/api/v4/read_table").as_deref(),
Some("https://data.example.net/api/v4/read_table")
);
assert_eq!(
resolve(request, "//data.example.net/api/v4").as_deref(),
Some("http://data.example.net/api/v4")
);
assert_eq!(
resolve(request, "/api/v4/exists?path=//tmp").as_deref(),
Some("http://proxy.example.net:8000/api/v4/exists?path=//tmp")
);
assert_eq!(
resolve(request, "read_table").as_deref(),
Some("http://proxy.example.net:8000/api/v4/read_table")
);
assert_eq!(
resolve(request, "?path=//other").as_deref(),
Some("http://proxy.example.net:8000/api/v4/exists?path=//other")
);
assert_eq!(
resolve(request, "#frag").as_deref(),
Some("http://proxy.example.net:8000/api/v4/exists?path=//tmp#frag")
);
assert_eq!(
resolve("http://h/api/v4/exists?path=//tmp#old", "?path=//other").as_deref(),
Some("http://h/api/v4/exists?path=//other")
);
assert_eq!(
resolve("http://h", "?path=//tmp").as_deref(),
Some("http://h?path=//tmp")
);
assert_eq!(
resolve("http://h", "read_table").as_deref(),
Some("http://h/read_table")
);
assert_eq!(
resolve(request, " /hosts ").as_deref(),
Some("http://proxy.example.net:8000/hosts")
);
assert_eq!(resolve(request, ""), None);
assert_eq!(resolve("proxy.example.net", "/hosts"), None);
}
#[test]
fn a_scheme_is_told_from_a_path() {
assert!(has_scheme("https://h/x"));
assert!(has_scheme("HTTP://h/x"));
assert!(!has_scheme("/api/v4/read:table"));
assert!(!has_scheme("//h/x"));
assert!(!has_scheme("read_table"));
assert!(!has_scheme("://h"));
assert!(!has_scheme("8000:80"));
}
#[test]
fn an_origin_is_scheme_host_and_port() {
assert!(same_origin(
"http://proxy.example.net/api/v4/exists",
"http://proxy.example.net/api/v4/read_table?path=//tmp"
));
assert!(same_origin("https://h/x", "https://h:443/x"));
assert!(same_origin(
"http://H.example.net/x",
"http://h.example.net/x"
));
assert!(!same_origin("http://h/x", "https://h/x"));
assert!(!same_origin("http://h/x", "http://other/x"));
assert!(!same_origin("http://h/x", "http://h:8000/x"));
assert!(!same_origin(
"http://real.example.net/x",
"http://real.example.net@evil.example.net/x"
));
assert!(!same_origin("not a url", "not a url"));
assert!(!same_origin("ftp://h/x", "ftp://h/x"));
}
#[test]
fn the_heavy_commands_are_the_ones_that_carry_a_stream() {
for command in [
"read_table",
"write_table",
"read_file",
"write_file",
"get_job_input",
"get_job_stderr",
] {
assert!(HEAVY.contains(&command), "{command}");
}
assert!(HEAVY.contains(&"read_blob_table"));
for command in ["create", "exists", "start_operation", "get_job", "hosts"] {
assert!(!HEAVY.contains(&command), "{command}");
}
}
fn serving(payload: &[u8], gzip: bool) -> String {
use std::io::Write;
let (encoding, body) = if gzip {
use flate2::{Compression, write::GzEncoder};
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(payload).expect("compresses");
(
"Content-Encoding: gzip\r\n",
encoder.finish().expect("finishes"),
)
} else {
("", payload.to_vec())
};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
let address = listener.local_addr().expect("has an address");
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accepts");
let mut reader = std::io::BufReader::new(stream.try_clone().expect("clones"));
drain_request(&mut reader);
let mut reply = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\
{encoding}Content-Length: {}\r\n\r\n",
body.len()
)
.into_bytes();
reply.extend_from_slice(&body);
stream.write_all(&reply).ok();
stream.flush().ok();
});
format!("http://{address}")
}
fn serving_endless_empty_deflate() -> String {
use std::io::Write;
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
let address = listener.local_addr().expect("has an address");
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accepts");
let mut reader = std::io::BufReader::new(stream.try_clone().expect("clones"));
drain_request(&mut reader);
if stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\
Content-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n",
)
.is_err()
{
return;
}
let mut empty_blocks = Vec::new();
for _ in 0..256 {
empty_blocks.extend_from_slice(&[0x00, 0x00, 0x00, 0xff, 0xff]);
}
let mut payload = vec![0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff];
payload.extend_from_slice(&empty_blocks);
loop {
let framed = format!("{:x}\r\n", payload.len());
if stream.write_all(framed.as_bytes()).is_err()
|| stream.write_all(&payload).is_err()
|| stream.write_all(b"\r\n").is_err()
|| stream.flush().is_err()
{
return;
}
payload.clone_from(&empty_blocks);
}
});
format!("http://{address}")
}
fn drain_request(reader: &mut impl std::io::BufRead) {
let mut head = String::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => return,
Ok(_) if line == "\r\n" => break,
Ok(_) => head.push_str(&line),
}
}
let header = |name: &str| {
head.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case(name)
.then(|| value.trim().to_owned())
})
};
if header("transfer-encoding").is_some_and(|value| value.eq_ignore_ascii_case("chunked")) {
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return;
}
let Ok(size) = usize::from_str_radix(line.trim(), 16) else {
return;
};
let mut chunk = vec![0; size + 2];
if reader.read_exact(&mut chunk).is_err() || size == 0 {
return;
}
}
}
if let Some(length) = header("content-length").and_then(|value| value.parse().ok()) {
let mut body = vec![0_u8; length];
let _ = reader.read_exact(&mut body);
}
}
fn incompressible(n: usize) -> Vec<u8> {
let mut state = 0x2545_f491_4f6c_dd1d_u64;
(0..n)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 33) as u8
})
.collect()
}
fn capped(base: &str, limit: u64) -> Transport {
let mut transport = Transport::new(base, None, Duration::from_secs(10));
transport.set_response_limit(limit);
transport
}
fn read_file_capped(base: &str, limit: u64) -> Result<Vec<u8>> {
capped(base, limit).send(
base,
Method::Get,
"read_file",
&map([("path", string("//tmp/f"))]),
&Payload::None,
)
}
fn upload_capped(base: &str, limit: u64) -> Result<Vec<u8>> {
capped(base, limit).upload(
Method::Put,
"write_table",
&map([("path", string("//tmp/t"))]),
&mut std::io::empty(),
)
}
#[test]
fn the_cap_counts_the_bytes_held_and_not_the_bytes_transferred() {
let error = read_file_capped(&serving(&vec![0_u8; 40_000], true), 4_096)
.expect_err("the cap is reached");
assert!(
matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
"{error:?}"
);
assert!(!crate::retry::is_retriable(&error), "{error}");
assert!(!crate::retry::worth_asking_again(&error), "{error}");
assert!(!crate::retry::attributable_to_the_host(&error), "{error}");
let message = error.to_string();
assert!(message.contains("4096"), "{message}");
assert!(message.contains("read_file_streaming"), "{message}");
}
#[test]
fn a_body_of_exactly_the_cap_is_not_over_it() {
let held = read_file_capped(&serving(&vec![7_u8; 4_096], true), 4_096)
.unwrap_or_else(|e| panic!("fits exactly, but {e}"));
assert_eq!(held, vec![7_u8; 4_096]);
for gzip in [true, false] {
let error = read_file_capped(&serving(&vec![7_u8; 4_097], gzip), 4_096)
.expect_err("one byte past the cap");
assert!(
matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
"gzip={gzip}: {error:?}"
);
}
}
#[test]
fn the_wire_backstop_leaves_room_for_a_body_it_must_not_refuse() {
let awkward = incompressible(4_096);
let compressed = {
use std::io::Write;
let mut encoder =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&awkward).expect("compresses");
encoder.finish().expect("finishes").len()
};
assert!(
compressed > 4_096,
"this needs a body gzip makes bigger, and {compressed} is not one"
);
let held = read_file_capped(&serving(&awkward, true), 4_096)
.unwrap_or_else(|e| panic!("{compressed} wire bytes for 4096 held, and {e}"));
assert_eq!(held, awkward);
let held = read_file_capped(&serving(&vec![7_u8; 4_096], false), 4_096)
.unwrap_or_else(|e| panic!("uncompressed and exactly the cap, but {e}"));
assert_eq!(held, vec![7_u8; 4_096]);
}
#[test]
fn an_endless_body_that_decodes_to_nothing_is_still_bounded() {
let (done, answer) = std::sync::mpsc::channel();
let base = serving_endless_empty_deflate();
std::thread::spawn(move || {
let _ = done.send(read_file_capped(&base, 4_096));
});
let outcome = answer
.recv_timeout(Duration::from_secs(20))
.expect("a body that never ends must still be refused, and was not");
let error = outcome.expect_err("nothing decoded, so there is nothing to hand back");
assert!(
matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
"{error:?}"
);
}
#[test]
fn the_cap_a_transport_is_built_with_is_the_documented_one() {
let transport = Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
assert_eq!(transport.response_limit, RESPONSE_LIMIT);
assert_eq!(RESPONSE_LIMIT, 512 * 1024 * 1024);
}
#[test]
fn a_response_this_client_will_not_hold_fails_the_upload_that_got_it() {
let error = upload_capped(&serving(&vec![0_u8; 40_000], true), 4_096)
.expect_err("the answer is past the cap");
assert!(
matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
"{error:?}"
);
let body = upload_capped(&serving(b"{\"value\"={}}", true), 4_096).expect("fits");
assert_eq!(body, b"{\"value\"={}}");
}
#[test]
fn a_body_over_the_cap_blames_the_request_and_not_the_proxy_that_served_it() {
let file = body_failure(
"read_file",
RESPONSE_LIMIT,
ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
)
.to_string();
assert!(file.contains("536870912"), "{file}");
assert!(file.contains("read_file_streaming"), "{file}");
let table = body_failure(
"read_table",
RESPONSE_LIMIT,
ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
)
.to_string();
assert!(table.contains("read_table_streaming"), "{table}");
let get = body_failure(
"get",
RESPONSE_LIMIT,
ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
)
.to_string();
assert!(!get.contains("streaming"), "{get}");
let quoted = body_failure(
"read_file",
RESPONSE_LIMIT,
ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT + 1),
)
.to_string();
assert!(quoted.contains("536870912"), "{quoted}");
}
#[test]
fn a_body_cut_short_is_still_the_network_failure_it_always_was() {
let error = body_failure(
"read_file",
RESPONSE_LIMIT,
ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"connection reset by peer",
)),
);
assert!(matches!(error, ClientError::Transport { .. }), "{error:?}");
assert!(crate::retry::is_retriable(&error), "{error}");
assert!(crate::retry::attributable_to_the_host(&error), "{error}");
}
#[test]
fn a_deadline_is_shared_out_and_then_refused() {
let command = "exists";
assert!(remaining(None, command).expect("no deadline").is_none());
let ahead = Instant::now() + Duration::from_secs(30);
let left = remaining(Some(ahead), command)
.expect("still time")
.expect("a bound");
assert!(left <= Duration::from_secs(30) && left > Duration::from_secs(29));
let error = remaining(Some(Instant::now() - Duration::from_millis(1)), command)
.expect_err("the budget is gone");
assert!(matches!(error, ClientError::Transport { .. }), "{error:?}");
assert!(error.to_string().contains("timeout"), "{error}");
assert!(crate::retry::is_retriable(&error), "{error:?}");
}
}