ytsaurus_client/http.rs
1//! HTTP transport for the YTsaurus API v4.
2//!
3//! The protocol, from
4//! <https://ytsaurus.tech/docs/en/user-guide/proxy/http-reference>:
5//!
6//! - commands live at `/api/v4/<command>`;
7//! - `X-YT-Header-Format` says how the other `X-YT-*` headers are encoded; this
8//! client uses text YSON for all of them;
9//! - command parameters go in `X-YT-Parameters`, not the query string or body,
10//! which keeps the body free for the data stream;
11//! - the body is the input stream for commands that take one;
12//! - failures are reported in `X-YT-Error`.
13//!
14//! # A known gap: trailers
15//!
16//! The proxy can only discover some failures *after* it has begun streaming a
17//! 200 response, and reports those in an `X-YT-Error` **trailer** rather than a
18//! header. `ureq` 3.3 exposes no trailers, so this client cannot read them.
19//!
20//! Rechecked against `ureq` 3.3's own source rather than carried forward as an
21//! assumption: the string "trailer" does not appear in it.
22//!
23//! Rather than pretend the gap does not exist, the client checks what it can:
24//! a truncated data stream is caught by validating that the response is a
25//! complete YSON list fragment (see `Client::read_table`), and on the streaming
26//! path — which never has the whole thing to validate — by the decoder failing
27//! on the record that was cut in half. A mid-stream failure that still produces
28//! well-formed output would go unnoticed either way: in practice a partial read
29//! reported as success.
30//!
31//! # Where a command is sent
32//!
33//! Not every command goes to the address the client was configured with. A
34//! large installation gives its proxies roles, and a *control* proxy will not
35//! serve a heavy request — so the heavy ones ask `/hosts` where they should
36//! go. [`Transport::base_for`] is that decision and [`HeavyProxy`] is what it
37//! remembers.
38//!
39//! What a control proxy does with a heavy request depends on whether the
40//! request carries **input data**, and this client's own error rendering hides
41//! the difference — the status is not in the message, only the cluster's error
42//! document is. From `TContext::TryRedirectHeavyRequests` in
43//! [`yt/yt/server/http_proxy/context.cpp`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/server/http_proxy/context.cpp):
44//!
45//! - a heavy command **with** an input stream — `write_table`, `write_file` —
46//! is refused with **503** and `Retry-After: 60`, carrying the error
47//! `Control proxy may not serve heavy requests with input data`;
48//! - a heavy command **without** one — `read_table`, `read_file`,
49//! `get_job_input`, `get_job_stderr` — is answered with a **307** to a data
50//! proxy, or with 503 and `There are no data proxies available` if there is
51//! none.
52//!
53//! The documentation gives both halves and neither whole: the
54//! [`/hosts` section](https://ytsaurus.tech/docs/en/user-guide/proxy/http-reference#hosts)
55//! says "light proxies return code 503", and the return-code table on the same
56//! page lists "307 — Redirecting heavy queries from light to heavy proxies".
57//! The input-data test above is what decides which.
58//!
59//! Discovery is what keeps either from happening. The lookup gets its **own**
60//! budget rather than the client's — see [`HOSTS_TIMEOUT`] — because it sits in
61//! front of the first heavy command and a proxy that cannot answer it in that
62//! time has not earned the wait.
63
64use std::path::Path;
65use std::sync::{Arc, Mutex, MutexGuard};
66use std::time::{Duration, Instant};
67
68use ureq::SendBody;
69use ureq::http::HeaderMap;
70use ytsaurus_yson::{YsonFormat, YsonValue, to_string};
71
72use crate::error::{ClientError, RedirectRefusal, Result, truncate};
73use crate::retry::{MutationId, Repeatable, RetryPolicy};
74use crate::yson_build::{boolean, insert, string};
75
76const HEADER_FORMAT: &str = "X-YT-Header-Format";
77const PARAMETERS: &str = "X-YT-Parameters";
78const ERROR: &str = "X-YT-Error";
79/// Where a redirect points. Read by this client rather than by `ureq` — see
80/// [`Transport::redirect`].
81const LOCATION: &str = "Location";
82/// How many redirects one request may follow before the chain is called a loop.
83///
84/// `ureq`'s own default, kept so that turning the following over to this client
85/// changed the policy and not the numbers.
86const MAX_REDIRECTS: usize = 10;
87
88/// How much of a response a buffered command will hold in memory — 512 MiB,
89/// counted **after** decompression.
90///
91/// Buffered responses are small (a table or file read is the exception, and a
92/// launcher reads results, not bulk data), and `ureq`'s own default is
93/// conservative enough to truncate a modest table silently, which is the
94/// failure this number exists to avoid. It is not a promise that half a
95/// gigabyte in a `Vec` is a good idea: the two commands that reach it in
96/// practice — [`Client::read_table`](crate::Client::read_table) and
97/// [`Client::read_file`](crate::Client::read_file) — each have a streaming
98/// half that holds nothing, and the error names it (see [`body_failure`]).
99///
100/// **`ureq`'s own `limit()` cannot enforce this**, which is why [`CapReader`]
101/// exists. `BodyWithConfig::do_build` wraps the raw body source in a
102/// `LimitReader` and then builds the gzip decoder *on top of it*, so the
103/// number it is given bounds what arrives **on the wire** — not what lands in
104/// the `Vec`. This client always asks for compression (`ureq`'s `gzip` feature
105/// is on, and `tests/request_shape.rs` pins the header), so those are not the
106/// same quantity and are not close to it. Measured against a local cluster: a
107/// `read_file` of a 5 000 000-byte file of zeros answers `Content-Encoding:
108/// gzip` in **4 892 wire bytes**, and `ureq` 3.3 asked for `.limit(100_000)`
109/// on that same read hands back all 5 000 000 without an error — fifty times
110/// its limit. At that ratio a 512 MiB *wire* cap would admit hundreds of
111/// gigabytes into memory, which is the OOM the documentation used to promise
112/// could not happen.
113///
114/// So the cap is applied where the bytes accumulate: [`CapReader`] sits above
115/// the decoder and counts what comes out of it.
116///
117/// **It bounds what is held, not what a process needs.** The bytes land in a
118/// `Vec` that grows by doubling and copies as it grows, so the old buffer and
119/// the new one are both resident for the length of a copy — about 1.5× the cap
120/// where the allocator cannot extend in place. Measured here, in a release
121/// build, against a listener serving gzipped zeros: a read of 536 870 911 bytes
122/// peaks at **544 178 176** of resident set for the 512 MiB it hands back, and
123/// a 600 MiB read *refused* by this cap peaks at **611 385 344** — 1.14× the
124/// number the error quotes. So `512 MiB` is what this client will hold, not
125/// what to size a container for.
126///
127/// **It covers the two buffered reads and not the crate.** [`Transport::send`]
128/// and [`Transport::upload`] read through [`read_capped`]; two other places
129/// still take `ureq`'s wire-only default — the non-2xx branch of
130/// [`Transport::open`] and the `/hosts` lookup in [`Transport::fetch`] — and a
131/// gzipped body there is bounded on the wire, which is the ratio above all over
132/// again. Both read an answer this client is about to fail on, so neither is
133/// reached in the ordinary case; neither is bounded in memory either.
134const RESPONSE_LIMIT: u64 = 512 * 1024 * 1024;
135
136/// The commands that carry a data stream, and so belong on a heavy proxy.
137///
138/// The
139/// [command reference](https://ytsaurus.tech/docs/en/api/commands) draws the
140/// line for us — *"light commands only transmit command parameters within a
141/// query, but heavy commands write or read the data stream"* — and marks each
142/// of `read_table`, `write_table`, `read_file`, `write_file` and
143/// `read_blob_table` **Heavy**. `get_job_input` and `get_job_stderr` are here
144/// on the same definition rather than on rows of their own: their answer *is*
145/// the data stream, which is why this crate reads the first through
146/// [`Transport::open`] and why `get_job_stderr` hands back bytes rather than
147/// text.
148///
149/// The list is what the **cluster** declares heavy, not what this crate
150/// happens to model: `read_blob_table` has no method here and is reachable
151/// through
152/// [`Client::raw_command_streaming`](crate::Client::raw_command_streaming), so
153/// leaving it out would take the advice away from exactly the caller who went
154/// to the trouble of streaming. (`read_file` sat beside it until it grew
155/// [`Client::read_file`](crate::Client::read_file); its entry below predates
156/// the method and is unchanged by it.)
157///
158/// Used for one thing only — whether a refused redirect is told to go to a
159/// heavy proxy. A command sent through
160/// [`Client::raw_command`](crate::Client::raw_command) that is heavy and not
161/// listed here loses the advice, not the refusal.
162///
163/// **The same fact is written down twice.** [`Repeatable::Heavy`] encodes the
164/// cluster's `isHeavy` bit for *routing* (#38, since merged), and this list
165/// encodes it for the redirect advice; they say the same thing about the same
166/// commands. A command routed to a heavy proxy but missing here is refused a
167/// redirect with `heavy: false` and told nothing it can act on, so a new
168/// `Repeatable::Heavy` call site must be checked against this list. The one
169/// entry that has no call site to check against is `read_blob_table`, and
170/// that is the point of the paragraph above.
171const HEAVY: &[&str] = &[
172 "read_table",
173 "write_table",
174 "read_file",
175 "write_file",
176 "read_blob_table",
177 "get_job_input",
178 "get_job_stderr",
179];
180
181/// Whether `command` is one the cluster declares heavy.
182///
183/// Read by the redirect advice, and by
184/// [`BatchRequest::raw`](crate::BatchRequest::raw) for a **narrower** job than
185/// it once had. `isHeavy` is not the cluster's rule for what may be a batch
186/// part — that rule is the command's data types, and lives in
187/// `batch::NOT_A_BATCH_PART`; measured, `get_job_spec` is heavy and is taken as
188/// a part, while `write_table` is heavy and is taken as a part *and applies*.
189/// What this list still decides for a batch is this crate's own policy: bulk
190/// data does not travel inline in a batch body to a light proxy, whatever the
191/// cluster would tolerate.
192pub(crate) fn is_heavy(command: &str) -> bool {
193 HEAVY.contains(&command)
194}
195
196/// The W3C trace context, in the spelling the proxy parses. See
197/// [`TraceContext`](crate::TraceContext).
198const TRACEPARENT: &str = "traceparent";
199/// The vendor state the standard pairs with `traceparent`. The proxy has no
200/// opinion about it; a caller's own backend may well have one, and a
201/// participant that forwards the one header is required to forward the other.
202const TRACESTATE: &str = "tracestate";
203
204/// The parameter that puts a command inside a transaction.
205const TRANSACTION_ID: &str = "transaction_id";
206
207/// A PEM file of root certificates to verify the cluster against, instead of
208/// the Mozilla bundle `ureq` compiles in. See [`root_certs`].
209///
210/// Behind the feature like everything else it leads to: a build with no TLS in
211/// it has no handshake to configure, and reads the variable no more than it
212/// opens a socket for `https://`.
213#[cfg(feature = "tls")]
214const CA_BUNDLE: &str = "YT_CA_BUNDLE";
215
216/// The most a root bundle may weigh.
217///
218/// Mozilla's own — `/etc/ssl/certs/ca-certificates.crt`, the largest thing
219/// anyone is likely to name here — is about 200 KB, so this is three orders of
220/// magnitude of headroom. It exists because the read has no other bound: the
221/// client's global timeout covers requests, not files, and
222/// [`Client::new`](crate::Client::new) is infallible, so a `YT_CA_BUNDLE`
223/// pointing at something enormous by accident would be paid for in memory
224/// before anyone could be told. Measured on a 512 MB file: 18.7 s and 1.27 GB
225/// of resident memory, for a bundle that was never going to parse.
226#[cfg(feature = "tls")]
227const MAX_BUNDLE_BYTES: u64 = 16 * 1024 * 1024;
228
229/// The cluster's own words when a proxy refuses a command because of the role
230/// it has.
231///
232/// `Control proxy may not serve heavy requests with input data`, from
233/// `TContext::TryRedirectHeavyRequests`. It is the only failure here that names
234/// the *addressee* rather than the request, which is why two places read it: a
235/// proxy that says it is worth asking the cluster for another one
236/// ([`crate::retry::worth_asking_again`]), and a caller who got it at the
237/// address they configured is owed the sentence that says why nothing routed it
238/// away ([`refusal_hint`]).
239pub(crate) const CONTROL_REFUSAL: &str = "may not serve heavy requests";
240
241/// Commands that have no transaction to be in.
242///
243/// These go to the scheduler and the controller agents rather than to the
244/// master, and take no `TTransactionalOptions`. Stamping them works only for as
245/// long as the proxy quietly drops parameters it does not recognise; on a
246/// cluster or a version that refuses them instead, every transaction-scoped
247/// launcher would fail at its first `wait_for_operation`.
248///
249/// `start_operation` is deliberately *not* here: an operation genuinely can run
250/// inside a transaction, which is how its output tables stay invisible until
251/// the launcher commits.
252///
253/// `execute_batch` is here for a different reason than its neighbours: it is
254/// served by the proxy's own driver, but its options are `TExecuteBatchOptions
255/// : TMutatingOptions` — no transactional half — so an outer `transaction_id`
256/// means nothing. **Measured on a local cluster**: a batch stamped with one
257/// created its node *outside* the transaction, visible at once and untouched
258/// by the abort. `Client::execute_batch` stamps the transaction into each
259/// part's parameters instead, which the same measurement shows the cluster
260/// honours; the entry here keeps the blanket stamp from dressing the envelope
261/// up in a parameter the cluster is known to drop.
262const NO_TRANSACTION: &[&str] = &[
263 "execute_batch",
264 "get_operation",
265 "list_operations",
266 "list_operation_events",
267 "abort_operation",
268 "complete_operation",
269 "suspend_operation",
270 "resume_operation",
271 "update_operation_parameters",
272 "list_jobs",
273 "get_job",
274 "get_job_stderr",
275 "get_job_input",
276 "abort_job",
277 "poll_job_shell",
278];
279
280/// Whether `command` is one the blanket transaction stamp skips.
281///
282/// Read by `Client::execute_batch` as well as by
283/// [`Transport::in_transaction`], because a batch *part* is a command too: a
284/// `get_operation` that takes no `transaction_id` outside a batch takes none
285/// inside one, and two copies of the list would drift.
286pub(crate) fn takes_no_transaction(command: &str) -> bool {
287 NO_TRANSACTION.contains(&command)
288}
289
290/// Applies a header list to either builder flavour.
291///
292/// `ureq` gives requests with and without a body distinct builder types, so a
293/// plain function cannot decorate both. A macro can.
294macro_rules! with_headers {
295 ($request:expr $(, $headers:expr)* $(,)?) => {{
296 let mut request = $request;
297 $(
298 for (name, value) in $headers {
299 request = request.header(*name, value.as_str());
300 }
301 )*
302 request
303 }};
304}
305
306/// How the command's payload is carried.
307pub(crate) enum Payload<'a> {
308 /// No request body.
309 None,
310 /// Raw bytes, for commands like `write_file`.
311 Bytes(&'a [u8]),
312}
313
314/// The request body, in a form one request can send more than once.
315///
316/// `ureq`'s [`SendBody`] is one-shot by construction — it may be a reader that
317/// has already been drained — so following a redirect needs the body kept as
318/// something that can produce a fresh `SendBody` per hop.
319///
320/// Two questions are asked of it when a `3xx` arrives, and they are not the
321/// same question. **Can this request be sent again?** — no, if it is a reader
322/// ([`Outgoing::replayable`], [`RedirectRefusal::Body`]). **Would sending it
323/// again hand someone data?** — yes, if there are bytes in it
324/// ([`Outgoing::carries_data`], [`RedirectRefusal::Payload`]). A body of length
325/// zero answers no to the second and yes to the first, which is why an empty
326/// slice is not the same thing as a table full of rows.
327enum Outgoing<'a> {
328 /// No body at all — neither `Content-Length` nor `Transfer-Encoding`.
329 ///
330 /// [`Transport::open`]'s request, which is a `GET` for everything this
331 /// crate models and reaches `ureq`'s body-carrying builder only through
332 /// [`Client::raw_command_streaming`](crate::Client::raw_command_streaming).
333 /// Distinct from `Bytes(&[])`, which is a body of length zero: what goes
334 /// on the wire differs, and this is the one that always sent nothing.
335 Empty,
336 /// Bytes held in memory, and so sent again to wherever a redirect points.
337 ///
338 /// An empty slice belongs here rather than in [`Outgoing::Empty`]: most of
339 /// API v4 carries its parameters in `X-YT-Parameters` and its payload
340 /// nowhere, and such a command has always gone out as `Content-Length: 0`.
341 /// A body of length zero is still a body a `GET` could not have carried —
342 /// and still nothing a redirect can lose or give away.
343 Bytes(&'a [u8]),
344 /// A body read as it is sent — [`Client::write_table_rows`](crate::Client::write_table_rows)
345 /// and every [`Client::raw_command_upload`](crate::Client::raw_command_upload).
346 ///
347 /// A reader cannot be rewound, and by the time a `3xx` arrives some of it
348 /// has already gone out, so a redirect on one of these is refused.
349 Stream(&'a mut dyn std::io::Read),
350}
351
352impl Outgoing<'_> {
353 /// Whether a redirect on this request could send the same request again.
354 fn replayable(&self) -> bool {
355 !matches!(self, Outgoing::Stream(_))
356 }
357
358 /// Whether there are bytes here that a redirect would be giving away.
359 ///
360 /// A body of length zero is not data. `Content-Length: 0` is what a `POST
361 /// create` sends and what a `GET` does not send at all; neither has
362 /// anything in it that a caller would mind another host receiving, so
363 /// neither is a reason to refuse a hop the credentials rule allows.
364 fn carries_data(&self) -> bool {
365 match self {
366 Outgoing::Empty => false,
367 Outgoing::Bytes(bytes) => !bytes.is_empty(),
368 Outgoing::Stream(_) => true,
369 }
370 }
371}
372
373/// How long the whole `/hosts` lookup may take.
374///
375/// **Not the client's request timeout, and not its retry policy.** This
376/// question sits in front of the first heavy command, its answer is a few
377/// hundred bytes from a proxy the client is already talking to, and failing to
378/// get one is not fatal — the command goes where it would have gone before
379/// there was a lookup at all. Under the client's own policy it was five
380/// attempts of up to two minutes with fifteen seconds of backoff between them,
381/// so a `/hosts` that answered 503 cost a heavy command **fifteen seconds** and
382/// one that hung cost it **ten minutes**, all of it under the mutex.
383///
384/// One attempt, then. The retry is [`HOSTS_RETRY_AFTER`] rather than a second
385/// attempt inside the lock: spreading it out is what keeps a client whose
386/// `/hosts` is down from paying for the answer over and over.
387const HOSTS_TIMEOUT: Duration = Duration::from_millis(800);
388
389/// How long the configured address serves heavy commands after a **lookup**
390/// that did not settle, before the cluster is asked again.
391///
392/// A lookup that failed for a reason that might pass means "use the address the
393/// caller gave, and ask again in a moment" rather than "ask again now", which
394/// is what turned eight threads into eight lookups.
395///
396/// **A failed heavy *command* is not this.** Its answer is dropping the host
397/// it used from the pool — see [`Transport::after_heavy`] — and only a pool
398/// with nobody left in it comes back here. Falling back on the first failure
399/// is what made a single transient 503 route the next ten seconds of uploads
400/// to a control proxy that refuses every one of them, which is the symptom
401/// this whole feature exists to prevent. **A failed *refresh* is not this
402/// either**: the pool in hand still routes, so the question is simply put off
403/// for another [`HOST_LIST_REFRESH_INTERVAL`] — see [`Transport::base_for`].
404///
405/// Short, because it is also how quickly routing comes back once the cluster
406/// does. Long enough that a client uploading in a loop against a broken
407/// `/hosts` pays [`HOSTS_TIMEOUT`] a handful of times a minute rather than
408/// once per upload. Settable — [`Transport::set_hosts_retry_after`] — because
409/// a constant nothing can move is a constant no test can tell from any other:
410/// with this fixed at ten seconds, nothing in the suite outlived one window, so
411/// [`HeavyProxy::Configured`] and [`HeavyProxy::FellBack`] were observationally
412/// identical and either could be swapped for the other with every test green.
413const HOSTS_RETRY_AFTER: Duration = Duration::from_secs(10);
414
415/// How old a `/hosts` answer may grow before a heavy command re-asks.
416///
417/// The [proxy guide](https://ytsaurus.tech/docs/en/user-guide/proxy/http#upload)
418/// asks for exactly this: "A good strategy is to re-query the `/hosts` list
419/// every minute or every few queries and change the current proxy to which
420/// queries are made." A minute, then — and lazily, on the heavy command that
421/// finds the list stale, the way the C++ client's `THostManager` does it,
422/// rather than from a background thread this crate would otherwise not need.
423///
424/// Settable — [`Transport::set_host_list_refresh_interval`] — for the same
425/// reason [`HOSTS_RETRY_AFTER`] is: a constant nothing can move is a constant
426/// no test can tell from any other, and "refreshed after the interval and not
427/// before" is one of the properties the tests pin.
428const HOST_LIST_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
429
430/// Where the cluster wants heavy commands sent.
431///
432/// Resolved on the first heavy command and *maintained* after that — refreshed
433/// when it grows old, a failed host dropped — rather than asked once and kept
434/// for the client's lifetime; see [`Transport::base_for`]. Shared by every
435/// clone, because `Client::with_transaction`, `Operation` and the diagnostics
436/// client are all clones of one client, and a lookup each would be a lookup
437/// per command.
438#[derive(Debug)]
439enum HeavyProxy {
440 /// The cluster has not been asked yet.
441 Unasked,
442 /// The answer `/hosts` gave, kept whole and picked from at random.
443 Pool(HeavyPool),
444 /// The cluster was asked and named none this client may use, so the
445 /// configured address serves heavy commands too. A single-node cluster, any
446 /// installation that does not separate the roles, and a `/hosts` whose
447 /// answer was refused — see [`heavy_base`].
448 ///
449 /// **A settled answer, not a pause — but settled for one refresh
450 /// interval, not for ever.** The difference from [`HeavyProxy::FellBack`]
451 /// is the clock it runs on: a failure that might pass is re-asked after
452 /// the short [`HOSTS_RETRY_AFTER`], where this is re-asked on the same
453 /// lazy [`HOST_LIST_REFRESH_INTERVAL`] as a pool. It used to be permanent,
454 /// and a permanent answer from one lookup is a pin: a launcher whose first
455 /// upload landed in the few seconds of a rolling restart when `/hosts`
456 /// answers `[]` would send every heavy command to the control proxy for
457 /// the rest of its life.
458 Configured {
459 /// When the cluster gave this answer.
460 asked: Instant,
461 },
462 /// The question did not settle, or the whole pool has now been dropped.
463 ///
464 /// The configured address serves heavy commands until `until`, and then the
465 /// cluster is asked once more. This is what a *waiting* thread finds rather
466 /// than an invitation to perform the same failing lookup itself, and what a
467 /// heavy command finds once every proxy in the answer has been tried.
468 FellBack {
469 /// When to ask again. See [`HOSTS_RETRY_AFTER`]. `None` for a window
470 /// so long no `Instant` can express its end —
471 /// `with_hosts_retry_after(Duration::MAX)` means a fallback that does
472 /// not end, and must not be a panic in `Instant` arithmetic instead.
473 until: Option<Instant>,
474 },
475}
476
477/// The heavy proxies this client is currently willing to use.
478///
479/// What the official clients keep and this crate did not: the C++ client's
480/// `THostManager` holds the whole `/hosts` answer and picks a random member
481/// per request, refreshing the list lazily when it has outlived its interval;
482/// the Go client's `ProxySet` does the same with a ban list beside it. This
483/// crate pinned the first name for the client's lifetime instead, and that
484/// divergence produced two real failures (#40): a per-host condition — a
485/// certificate valid for every proxy but one — pinned every upload to the one
486/// bad host for as long as the client lived, and a fleet of clients never
487/// rebalanced, each keeping whichever host its one lookup happened to name
488/// however the load moved afterwards.
489///
490/// So: never commit to one host. A pool, picked from at random per command; a
491/// host a command failed at is dropped and the next command picks from what
492/// remains; a refresh — the next heavy command after
493/// [`HOST_LIST_REFRESH_INTERVAL`] — rebuilds the pool from a fresh answer,
494/// which is also what restores a dropped host the cluster still vouches for.
495/// The restoration is deliberate and has a price: a *persistently* bad host —
496/// the misissued certificate that motivated #40 — is re-learned at one failed
497/// command per interval until somebody fixes it, which is the trade this
498/// crate makes against keeping a ban list with its own clock (Go's five
499/// minutes) for a condition that is always an operator's bug.
500#[derive(Debug)]
501struct HeavyPool {
502 /// Usable base URLs from the last `/hosts` answer, minus any dropped
503 /// since. Never empty: a pool with nothing left to pick from becomes
504 /// [`HeavyProxy::FellBack`] instead, which is a state that ends —
505 /// [`HeavyPool::drop_host`] says whether the pool survived, so the
506 /// emptying and the transition live at one call site.
507 hosts: Vec<String>,
508 /// When the answer these came from arrived. Age is judged against the
509 /// transport's own interval at the moment of asking, so clones of one
510 /// client — which share this state but may configure different intervals
511 /// — each honour their own, and an interval of `Duration::MAX` simply
512 /// never elapses rather than panicking in `Instant` arithmetic.
513 fetched: Instant,
514}
515
516impl HeavyPool {
517 /// One of the pool's hosts, picked at random.
518 ///
519 /// Random per command, as both official clients pick — the property is
520 /// load-spreading, not unpredictability, so the id source this crate
521 /// already has is entropy enough (its contract is *unique, not
522 /// unpredictable*, and `unique::word` records that this caller also
523 /// leans on its uniformity). The modulo bias against a 64-bit word is
524 /// beneath measuring for any real fleet.
525 fn pick(&self) -> &str {
526 let drawn = crate::unique::word(0) % self.hosts.len() as u64;
527 &self.hosts[drawn as usize]
528 }
529
530 /// Takes a failed host out of the pool until a refresh restores it, and
531 /// says whether the pool survived.
532 ///
533 /// By value, not by position: two commands in flight may both have gone to
534 /// the host that just failed, and the second drop must not evict an
535 /// innocent neighbour — or anything at all, once the first has already
536 /// done it. The return value is what keeps the "never empty" invariant at
537 /// the call site that could break it: a caller that drops must deal with
538 /// `false` or leave a pool [`HeavyPool::pick`] would divide by zero on.
539 #[must_use]
540 fn drop_host(&mut self, base: &str) -> bool {
541 self.hosts.retain(|host| host != base);
542 !self.hosts.is_empty()
543 }
544}
545
546/// Where one command was sent: an address this client chose out of `/hosts`,
547/// or the one the caller configured.
548///
549/// A fact carried from [`Transport::base_for`] to [`Transport::after_heavy`]
550/// rather than re-derived there, because the address alone cannot answer it:
551/// `/hosts` may perfectly well name the configured host — a caller pointed
552/// straight at a data proxy the coordinator also lists — and [`heavy_base`]
553/// then builds a base URL byte-identical to the configured one. Inferring
554/// "routed" by comparing strings read that case as "the caller's own choice",
555/// so the draining host was never dropped and the failure was explained with
556/// a sentence about routing being off. Which address was *chosen* is
557/// something only the chooser knows, so the chooser says so.
558enum Destination<'a> {
559 /// An address picked from the `/hosts` pool, owned because the pool the
560 /// pick came from may be gone by the time the failure is judged.
561 Discovered(String),
562 /// The address the caller gave, borrowed from the transport.
563 Configured(&'a str),
564}
565
566impl Destination<'_> {
567 /// The base URL to dial, whichever way it was arrived at.
568 fn address(&self) -> &str {
569 match self {
570 Self::Discovered(base) => base,
571 Self::Configured(base) => base,
572 }
573 }
574}
575
576/// Which of the names `/hosts` gives back this client is willing to use.
577///
578/// Four answers, because the two the crate shipped with were "everything the
579/// domain rule allows" and "everything at all" — and the only cure for a domain
580/// rule that misses by one label was to give up the control entirely. See
581/// [`heavy_base`] for what the rule is and, more to the point, what it is worth.
582#[derive(Clone, Debug)]
583enum HeavyHosts {
584 /// The configured address's own domain, the default. See [`same_domain`].
585 SameDomain,
586 /// That domain **and** the ones named here, which is what an installation
587 /// publishing its heavy proxies in a second zone actually has: a cluster at
588 /// `cluster.example.net` whose `/hosts` answers
589 /// `n0132-sas.rack7.proxy-zone.net` needs `proxy-zone.net`
590 /// added, not the rule removed. See [`under_domain`].
591 Under {
592 /// The domains as [`Transport::set_heavy_proxies_under`] normalised
593 /// them: lowercased, without wildcard, scheme, port or stray dots, and
594 /// without duplicates.
595 domains: Vec<String>,
596 /// What was handed in and could not be used — an entry with no dot left
597 /// in it, which would admit a whole top-level domain if honoured.
598 ///
599 /// **Kept in order to be reported.** The setter has no failure path, so
600 /// an entry it drops would otherwise vanish: the rule stays where it
601 /// was, every refusal reads exactly as if the caller had configured
602 /// nothing, and `YT_HEAVY_PROXY_DOMAINS=net` looks from the outside like
603 /// a variable that was ignored — which is the shape of the very problem
604 /// this mode exists to end. [`Declined::because`] names these.
605 ignored: Vec<String>,
606 },
607 /// Wherever `/hosts` says, checked for being a host name and nothing else.
608 Anywhere,
609 /// Exactly these names, compared without case — and a port only where both
610 /// sides name one, since `/hosts` usually names none and the port then
611 /// comes from the configured address.
612 ///
613 /// An empty list admits nothing, which is a way of saying "route nowhere"
614 /// — [`Transport::set_proxy_discovery`] is the way of saying it plainly.
615 Only(Vec<String>),
616}
617
618impl HeavyHosts {
619 /// Whether a discovered host is one this client may send a token to.
620 ///
621 /// `configured` is the base URL the caller gave; `discovered` is one entry
622 /// of the `/hosts` answer, trimmed and already known to be an authority.
623 fn admits(&self, configured: &str, discovered: &str) -> bool {
624 match self {
625 Self::SameDomain => same_domain(host_of(configured), host_of(discovered)),
626 Self::Under { domains, .. } => {
627 same_domain(host_of(configured), host_of(discovered))
628 || domains
629 .iter()
630 .any(|domain| under_domain(domain, host_of(discovered)))
631 }
632 Self::Anywhere => true,
633 Self::Only(names) => names.iter().any(|name| same_name(name, discovered)),
634 }
635 }
636}
637
638/// Whether a name a caller wrote out means the same proxy as a discovered one.
639///
640/// The host without case, and the port **only where both name one**: `/hosts`
641/// answers with bare host names unless the coordinator's `ShowPorts` says
642/// otherwise, so a list that had to spell the port out would be a list that
643/// usually matched nothing.
644fn same_name(listed: &str, discovered: &str) -> bool {
645 let listed = listed.trim();
646
647 if !host_of(listed).eq_ignore_ascii_case(host_of(discovered)) {
648 return false;
649 }
650 match (port_of(listed), port_of(discovered)) {
651 (Some(listed), Some(discovered)) => listed == discovered,
652 _ => true,
653 }
654}
655
656/// Why a name from `/hosts` was passed over.
657///
658/// Kept apart from the refusal itself so that the client can say which of the
659/// two happened: a name it could not read is a broken cluster or a forged
660/// answer, and a name it could read and declined is a configuration this
661/// operator can change in one line.
662#[derive(Clone, Copy, Debug, PartialEq, Eq)]
663enum Declined {
664 /// Not a host name: blank, or carrying a scheme, a path, userinfo,
665 /// whitespace, a bad port, or brackets around something that is not an
666 /// IPv6 literal.
667 Malformed,
668 /// A perfectly good name somewhere this client was not pointed.
669 Elsewhere,
670}
671
672impl Declined {
673 /// The half-sentence an operator needs, which depends on what was allowed.
674 fn because(self, allowed: &HeavyHosts, configured: &str) -> String {
675 match (self, allowed) {
676 (Self::Malformed, _) => "is not a host name".to_owned(),
677 (Self::Elsewhere, HeavyHosts::Only(_)) => {
678 "is not one of the names with_heavy_proxies_in was given".to_owned()
679 }
680 // The domains are named because the whole point of this mode is
681 // that one more was needed: an operator reading the refusal has to
682 // see the list they wrote, or the next guess is that it was ignored.
683 // And what was dropped is named for the same reason, the other way
684 // round: an entry that is not a domain would otherwise change
685 // nothing and say nothing, which reads exactly like a variable this
686 // client never looked at.
687 (Self::Elsewhere, HeavyHosts::Under { domains, ignored })
688 if !domains.is_empty() || !ignored.is_empty() =>
689 {
690 let mut why = format!("is not under the domain of {}", host_of(configured));
691 if !domains.is_empty() {
692 why.push_str(&format!(" or under {}", domains.join(", ")));
693 }
694 if !ignored.is_empty() {
695 why.push_str(&format!(" (ignored, not a domain: {})", ignored.join(", ")));
696 }
697 why
698 }
699 (Self::Elsewhere, _) => {
700 format!("is not under the domain of {}", host_of(configured))
701 }
702 }
703 }
704}
705
706/// A configured connection to one cluster.
707#[derive(Clone)]
708pub(crate) struct Transport {
709 agent: ureq::Agent,
710 /// The address the caller gave. Every light command goes here, and so does
711 /// a heavy one until the cluster names somewhere better.
712 base: String,
713 /// Where heavy commands go, once asked. See [`HeavyProxy`].
714 heavy: Arc<Mutex<HeavyProxy>>,
715 /// Whether to ask at all. Off for a cluster on loopback — see
716 /// [`is_local`] — and settable either way by the caller.
717 discovery: bool,
718 /// Which discovered hosts may be used. The configured address's own domain
719 /// by default — see [`heavy_base`].
720 hosts: HeavyHosts,
721 /// The whole budget for one `/hosts` lookup. [`HOSTS_TIMEOUT`] by default,
722 /// and its own field rather than a minimum with `timeout` so that a cluster
723 /// answering in 900 ms can be routed to at all.
724 hosts_timeout: Duration,
725 /// How long a fallback lasts before the cluster is asked again. See
726 /// [`HOSTS_RETRY_AFTER`].
727 hosts_retry_after: Duration,
728 /// How old a `/hosts` answer may grow before a heavy command re-asks. See
729 /// [`HOST_LIST_REFRESH_INTERVAL`].
730 host_list_refresh: Duration,
731 token: Option<String>,
732 retries: RetryPolicy,
733 /// End-to-end limit for buffered commands — one budget per attempt, shared
734 /// out between the redirect hops that attempt makes. Per-phase limit for
735 /// streaming ones. See [`Transport::dispatch`].
736 timeout: Duration,
737 /// How much of a buffered response this client will hold in memory:
738 /// [`RESPONSE_LIMIT`], counted after decompression.
739 ///
740 /// A field rather than the constant read at each of the places that need
741 /// it — [`Transport::send`] and [`Transport::upload`] — because a cap only
742 /// reachable by producing half a gigabyte is a cap no test reaches, and a
743 /// guard no test reaches can be deleted at any of them without anything
744 /// going red. It has one production value; the only thing that changes it
745 /// is `Transport::set_response_limit`, which does not exist outside
746 /// `cfg(test)` — and so does not exist in a rendered doc either.
747 response_limit: u64,
748 /// Stamped onto every command, when the client is bound to a transaction.
749 transaction: Option<String>,
750 /// The `traceparent` header, when the client was given a trace to belong
751 /// to.
752 trace: Option<String>,
753 /// The companion `tracestate`, carried unmodified when the context that
754 /// was joined had one. See [`TraceContext::tracestate`].
755 tracestate: Option<String>,
756 /// The headers that say who is asking, rendered once — see
757 /// [`Transport::render_caller_headers`]. None of them changes between
758 /// requests, so none of them is worth building again for each one.
759 caller: Vec<(&'static str, String)>,
760 /// Why the TLS configuration this build was asked for could not be
761 /// assembled — a `YT_CA_BUNDLE` that names nothing readable, or nothing
762 /// that parsed. Carried rather than reported, because an agent is built
763 /// before there is a request to fail; see [`Transport::unusable`].
764 ///
765 /// A `String` and not a [`ClientError`] because a `Transport` is `Clone`
766 /// and an error holding an `io::Error` is not.
767 tls_refused: Option<String>,
768}
769
770impl std::fmt::Debug for Transport {
771 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772 f.debug_struct("Transport")
773 .field("base", &self.base)
774 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
775 .finish()
776 }
777}
778
779impl Transport {
780 pub(crate) fn new(proxy: &str, token: Option<String>, timeout: Duration) -> Self {
781 let base = if proxy.starts_with("http://") || proxy.starts_with("https://") {
782 proxy.trim_end_matches('/').to_owned()
783 } else {
784 // A bare host means a real cluster, which is always TLS. Only an
785 // explicit `http://` opts out, which is how a local cluster is
786 // addressed.
787 format!("https://{}", proxy.trim_end_matches('/'))
788 };
789
790 // Quiet inside a job, where stderr is the cluster's diagnostic channel
791 // and not a terminal. See `retry::report_by_default`.
792 let retries = if crate::retry::report_by_default() {
793 RetryPolicy::default()
794 } else {
795 RetryPolicy::default().quiet()
796 };
797
798 let (agent, tls_refused) = build_agent(timeout, configured_bundle());
799
800 let mut transport = Self {
801 agent,
802 discovery: !is_local(&base),
803 hosts: HeavyHosts::SameDomain,
804 hosts_timeout: HOSTS_TIMEOUT,
805 hosts_retry_after: HOSTS_RETRY_AFTER,
806 host_list_refresh: HOST_LIST_REFRESH_INTERVAL,
807 base,
808 heavy: Arc::new(Mutex::new(HeavyProxy::Unasked)),
809 token,
810 retries,
811 timeout,
812 response_limit: RESPONSE_LIMIT,
813 transaction: None,
814 trace: None,
815 tracestate: None,
816 caller: Vec::new(),
817 tls_refused,
818 };
819 transport.render_caller_headers();
820 transport
821 }
822
823 pub(crate) fn set_retries(&mut self, policy: RetryPolicy) {
824 self.retries = policy;
825 }
826
827 /// Turns the `/hosts` lookup on or off, forgetting anything it found.
828 pub(crate) fn set_proxy_discovery(&mut self, enabled: bool) {
829 self.discovery = enabled;
830 self.forget_heavy();
831 }
832
833 /// Lets a discovered host be one outside the configured address's domain.
834 pub(crate) fn set_heavy_proxies_anywhere(&mut self, enabled: bool) {
835 self.hosts = if enabled {
836 HeavyHosts::Anywhere
837 } else {
838 HeavyHosts::SameDomain
839 };
840 self.forget_heavy();
841 }
842
843 /// Narrows discovered hosts to a list the caller wrote out.
844 pub(crate) fn set_heavy_proxies_in(&mut self, names: Vec<String>) {
845 self.hosts = HeavyHosts::Only(names);
846 self.forget_heavy();
847 }
848
849 /// Widens the domain rule by the domains named, keeping the configured
850 /// address's own.
851 ///
852 /// Normalised here rather than at each comparison: a domain arrives from a
853 /// configuration file or an environment variable as often as from a
854 /// literal, and every way a person writes one has to mean the same thing.
855 /// `*.Proxy-Zone.NET. `, `https://proxy-zone.net` and `proxy-zone.net:443`
856 /// all normalise to `proxy-zone.net` — the wildcard because that is how a
857 /// zone gets described in prose and in a certificate, the scheme and port
858 /// because [`same_name`] tolerates both for
859 /// [`crate::Client::with_heavy_proxies_in`] and a caller has no reason to
860 /// expect these two to differ.
861 ///
862 /// **An entry with no dot left in it is not used.** `net` is a plausible
863 /// typo for a real domain and would admit every `.net` host `/hosts` could
864 /// name, which is [`crate::Client::with_heavy_proxies_anywhere`] with extra
865 /// steps. [`same_domain`] never shortens below two labels for the same
866 /// reason; that floor is not the public-suffix argument, and dropping it
867 /// because a human typed the value rather than deriving it would be
868 /// backwards. `""` goes the same way — it would make the suffix test
869 /// `ends_with(".")`, which is no test at all.
870 ///
871 /// Such an entry is **kept in `ignored` and named in the refusal**, not
872 /// forgotten. This is a builder with no failure path, so dropping one in
873 /// silence would leave the rule where it was and every message reading
874 /// exactly as though the caller had configured nothing — an operator who
875 /// sets `YT_HEAVY_PROXY_DOMAINS` and sees no change learns nothing about
876 /// why, which is the shape of the problem this mode exists to end. See
877 /// [`Declined::because`].
878 ///
879 /// Duplicates go too, and the four spellings above are why: they all
880 /// normalise to one string, and a refusal listing `proxy-zone.net,
881 /// proxy-zone.net` reads as a bug in the client to the one person it is
882 /// written for. Order is the caller's, first mention winning.
883 pub(crate) fn set_heavy_proxies_under(&mut self, domains: Vec<String>) {
884 let mut kept: Vec<String> = Vec::with_capacity(domains.len());
885 let mut ignored: Vec<String> = Vec::new();
886
887 for domain in &domains {
888 // `host_of` first, then the dots: it reads a URL, so a trailing dot
889 // trimmed off `https://proxy-zone.net./` before it would leave the
890 // path behind and the dot in place.
891 let normalised = host_of(domain.trim())
892 .trim_start_matches('*')
893 .trim_matches('.')
894 .to_ascii_lowercase();
895
896 // An entry that is nothing at all is a list artefact — a trailing
897 // comma in `YT_HEAVY_PROXY_DOMAINS`, a blank line in a config — and
898 // reporting it would put "(ignored, not a domain: )" in front of
899 // somebody who did not write anything to be told about.
900 if normalised.is_empty() {
901 continue;
902 }
903
904 let (into, value) = if normalised.contains('.') {
905 (&mut kept, normalised)
906 } else {
907 (&mut ignored, domain.trim().to_owned())
908 };
909 if !into.contains(&value) {
910 into.push(value);
911 }
912 }
913
914 self.hosts = HeavyHosts::Under {
915 domains: kept,
916 ignored,
917 };
918 self.forget_heavy();
919 }
920
921 /// Overrides the budget for one `/hosts` lookup.
922 pub(crate) fn set_hosts_timeout(&mut self, timeout: Duration) {
923 self.hosts_timeout = timeout;
924 }
925
926 /// Overrides how long a fallback lasts before the cluster is asked again.
927 pub(crate) fn set_hosts_retry_after(&mut self, after: Duration) {
928 self.hosts_retry_after = after;
929 }
930
931 /// Overrides how old a `/hosts` answer may grow before it is refreshed.
932 pub(crate) fn set_host_list_refresh_interval(&mut self, interval: Duration) {
933 self.host_list_refresh = interval;
934 }
935
936 /// The address the caller configured, for a test that has to see where a
937 /// client was pointed without sending anything to it.
938 #[cfg(test)]
939 pub(crate) fn configured_address(&self) -> &str {
940 &self.base
941 }
942
943 /// Which discovered hosts this transport would use, rendered.
944 ///
945 /// `#[cfg(test)]`, and a string rather than the enum: [`HeavyHosts`] is
946 /// private to this module and worth keeping that way — the rule is chosen
947 /// through `Client`, not inspected.
948 #[cfg(test)]
949 pub(crate) fn heavy_hosts_debug(&self) -> String {
950 format!("{:?}", self.hosts)
951 }
952
953 /// Lowers the buffered-response cap, so a test can reach it.
954 ///
955 /// [`RESPONSE_LIMIT`] is half a gigabyte: a test that had to produce one to
956 /// watch the guard work is a test that would not be written, and the guard
957 /// would go unpinned at every site that applies it — which is how
958 /// [`Transport::upload`]'s came to swallow the failure unnoticed.
959 ///
960 /// `#[cfg(test)]` rather than `pub(crate)`: the cap is not a knob, and
961 /// nothing outside a test may widen it. See [`RESPONSE_LIMIT`] for why the
962 /// number is the number.
963 #[cfg(test)]
964 pub(crate) fn set_response_limit(&mut self, limit: u64) {
965 self.response_limit = limit;
966 }
967
968 /// Drops what discovery resolved, because the rules it resolved under have
969 /// changed.
970 ///
971 /// A fresh `Arc`, not a write through the shared one: these are builders on
972 /// a clone of the client, and narrowing the rules here must not discard what
973 /// the client this was cloned from has already resolved under the old ones.
974 fn forget_heavy(&mut self) {
975 self.heavy = Arc::new(Mutex::new(HeavyProxy::Unasked));
976 }
977
978 pub(crate) fn set_timeout(&mut self, timeout: Duration) {
979 self.timeout = timeout;
980 // Through `build_agent` rather than by editing the config in place:
981 // this is the one place the agent is built twice, and so the one place
982 // the redirect policy (max_redirects(0), from #36) could be dropped by
983 // a caller doing nothing more suspicious than `with_timeout`. The TLS
984 // refusal is rediscovered here too — the bundle is re-read, so a
985 // variable fixed since the client was built is picked up.
986 let (agent, tls_refused) = build_agent(timeout, configured_bundle());
987 self.agent = agent;
988 self.tls_refused = tls_refused;
989 }
990
991 pub(crate) fn set_transaction(&mut self, id: Option<String>) {
992 self.transaction = id;
993 }
994
995 pub(crate) fn transaction(&self) -> Option<&str> {
996 self.transaction.as_deref()
997 }
998
999 pub(crate) fn set_trace(&mut self, context: &crate::TraceContext) {
1000 self.trace = Some(context.header());
1001 self.tracestate = context.tracestate().map(str::to_owned);
1002 self.render_caller_headers();
1003 }
1004
1005 pub(crate) fn trace(&self) -> Option<&str> {
1006 self.trace.as_deref()
1007 }
1008
1009 pub(crate) fn tracestate(&self) -> Option<&str> {
1010 self.tracestate.as_deref()
1011 }
1012
1013 /// Executes a command, repeating it when the failure looks transient.
1014 ///
1015 /// `repeatable` says what the command allows: a read is simply re-sent, a
1016 /// light mutation is re-sent under a `mutation_id` the cluster
1017 /// deduplicates, and a heavy command is sent once whatever the policy says
1018 /// — and to the proxy the cluster named for heavy work, which is the other
1019 /// half of what [`Repeatable::Heavy`] declares.
1020 pub(crate) fn call(
1021 &self,
1022 method: Method,
1023 command: &str,
1024 parameters: &YsonValue,
1025 payload: Payload<'_>,
1026 repeatable: Repeatable,
1027 ) -> Result<Vec<u8>> {
1028 self.call_with(method, command, parameters, payload, repeatable, None)
1029 }
1030
1031 /// As [`Transport::call`], with a caller-supplied mutation ID.
1032 pub(crate) fn call_with(
1033 &self,
1034 method: Method,
1035 command: &str,
1036 parameters: &YsonValue,
1037 payload: Payload<'_>,
1038 repeatable: Repeatable,
1039 mutation_id: Option<&MutationId>,
1040 ) -> Result<Vec<u8>> {
1041 let mutation_id = match (repeatable, mutation_id) {
1042 (_, Some(given)) => Some(given.clone()),
1043 (Repeatable::WithMutationId, None) => Some(MutationId::new()),
1044 _ => None,
1045 };
1046
1047 let stamped = self.in_transaction(command, parameters);
1048 let parameters = stamped.as_ref().unwrap_or(parameters);
1049
1050 let base = self.base_for(repeatable);
1051 let sent = crate::retry::run(self.retries, repeatable, command, |is_retry| {
1052 match &mutation_id {
1053 Some(id) => {
1054 // The ID stays the same across attempts — that is what the
1055 // cluster deduplicates by — and only the flag changes. A
1056 // caller-supplied ID may already be marked as a replay,
1057 // which is how a restarted process resumes; the cluster
1058 // refuses a duplicate that does not say so.
1059 let mut tagged = parameters.clone();
1060 insert(&mut tagged, "mutation_id", string(id.as_str()));
1061 insert(&mut tagged, "retry", boolean(is_retry || id.is_retry()));
1062 self.send(base.address(), method, command, &tagged, &payload)
1063 }
1064 None => self.send(base.address(), method, command, parameters, &payload),
1065 }
1066 });
1067
1068 self.after_heavy(repeatable, &base, sent)
1069 }
1070
1071 /// Puts the client's transaction into a command's parameters.
1072 ///
1073 /// `None` when there is nothing to add, so the common case does not copy
1074 /// the parameters. One place rather than one per command: the cluster
1075 /// applies `transaction_id` to everything a transaction can contain, and a
1076 /// command this client forgot to stamp would silently do its work outside
1077 /// the transaction — the failure a transaction exists to prevent.
1078 ///
1079 /// A command that already names a transaction keeps the one it named:
1080 /// `commit_transaction` and its siblings mean a specific transaction, and
1081 /// that is exactly the one they are given.
1082 ///
1083 /// A command that has no transaction to be in is left alone — see
1084 /// [`NO_TRANSACTION`]. `Transaction` derefs to `Client`, so a launcher
1085 /// reaches `wait_for_operation` and its diagnostics through a bound client
1086 /// as a matter of course.
1087 fn in_transaction(&self, command: &str, parameters: &YsonValue) -> Option<YsonValue> {
1088 let id = self.transaction.as_ref()?;
1089
1090 if takes_no_transaction(command) {
1091 return None;
1092 }
1093
1094 if let ytsaurus_yson::YsonNode::Map(m) = ¶meters.node
1095 && m.contains_key(TRANSACTION_ID.as_bytes())
1096 {
1097 return None;
1098 }
1099
1100 let mut tagged = parameters.clone();
1101 insert(&mut tagged, TRANSACTION_ID, string(id));
1102 Some(tagged)
1103 }
1104
1105 /// Which address one command is sent to.
1106 ///
1107 /// Everything light goes to the address the caller configured. A heavy
1108 /// command — [`Repeatable::Heavy`], the `isHeavy` bit of the cluster's own
1109 /// command registry — goes to a proxy that will accept one: an installation
1110 /// that separates the roles will not serve a heavy request on a *control*
1111 /// proxy, and the balancer a caller is usually pointed at fronts exactly
1112 /// those. See the module documentation for what the refusal looks like.
1113 ///
1114 /// The lookup happens when the first heavy command needs it, and then
1115 /// again only when the answer has outlived the transport's refresh
1116 /// interval ([`HOST_LIST_REFRESH_INTERVAL`] unless overridden) — lazily,
1117 /// on the heavy command that finds it stale, never from a background
1118 /// thread. The **whole** answer is kept as a pool and every heavy command
1119 /// picks a member **at random**: `/hosts` is ordered by load, and a
1120 /// client that keeps its one pick for life never rebalances — a draining
1121 /// host keeps every client that ever picked it. A host a command failed
1122 /// at is dropped from the pool until a refresh restores it; see
1123 /// [`Transport::after_heavy`]. Both official clients do exactly this
1124 /// (`THostManager` in C++, `ProxySet` in Go), and the property they agree
1125 /// on is the one this preserves: **never commit to one host.**
1126 ///
1127 /// A refresh that does not produce a usable list — a failed lookup, an
1128 /// empty answer, an answer refused in full — keeps the pool it was
1129 /// refreshing and puts the question off for **another whole interval**.
1130 /// The hosts in hand are from an answer the cluster did give, so dropping
1131 /// them over a lookup hiccup would route uploads to a control proxy; and
1132 /// unlike the *initial* lookup, nothing here is waiting on the answer, so
1133 /// there is no urgency to justify the short [`HOSTS_RETRY_AFTER`] — a
1134 /// refresh retried on that clock against a down `/hosts` would put a
1135 /// lookup's stall in front of heavy traffic several times a minute,
1136 /// for an answer the pool makes unnecessary.
1137 ///
1138 /// A cluster that names nobody this client may use — a single-node
1139 /// installation, any that does not split the roles, and one whose answer
1140 /// [`heavy_base`] refused — is remembered as answered, and every heavy
1141 /// command then goes where it always went, until one refresh interval
1142 /// passes and the question is put once more. That fallback is not a
1143 /// nicety: it is the whole of what keeps a local cluster behaving as it
1144 /// did. A refused answer is also *said*, the first time it settles — see
1145 /// [`crate::observe::declined`] — because it is the one branch here that
1146 /// looks exactly like the bug this feature fixes; the re-asks that follow
1147 /// stay quiet rather than repeating the sentence once a minute.
1148 ///
1149 /// The mutex is held **across the lookup — the refresh included**,
1150 /// deliberately: a second thread that wanted a heavy proxy at the same
1151 /// moment waits for this answer rather than asking the same question
1152 /// again. What makes that safe rather than a queue is that every outcome
1153 /// leaves an answer — a pool, [`HeavyProxy::Configured`], or
1154 /// [`HeavyProxy::FellBack`] — with a clock on it, so the waiters find a
1155 /// decision and the stall is bounded: at most one lookup of at most
1156 /// [`Transport::hosts_timeout`] per interval, however many threads are
1157 /// uploading. Before that, eight threads against a failing `/hosts`
1158 /// performed eight lookups, each waiting out the one in front. `fetch`
1159 /// does not touch this lock, so there is nothing here to deadlock
1160 /// against.
1161 fn base_for(&self, repeatable: Repeatable) -> Destination<'_> {
1162 if repeatable != Repeatable::Heavy || !self.discovery {
1163 return Destination::Configured(&self.base);
1164 }
1165
1166 let mut resolved = lock(&self.heavy);
1167 match &mut *resolved {
1168 HeavyProxy::Pool(pool) => {
1169 // A stale pool is refreshed before it is picked from — the
1170 // documentation's own strategy, and lazily like the C++
1171 // client, so the client that stopped uploading also stopped
1172 // asking. Age is judged against this transport's interval, so
1173 // clones sharing the state each honour their own.
1174 if pool.fetched.elapsed() >= self.host_list_refresh {
1175 match self.usable_hosts() {
1176 Ok(hosts) if !hosts.is_empty() => {
1177 *pool = HeavyPool {
1178 hosts,
1179 fetched: Instant::now(),
1180 };
1181 }
1182 // Nothing usable — a failed lookup, or an answer with
1183 // nobody in it, which a fleet mid-rotation can
1184 // briefly give. The pool in hand keeps routing and
1185 // the question waits out another interval; see the
1186 // doc above for why not the short retry window.
1187 _ => pool.fetched = Instant::now(),
1188 }
1189 }
1190 return Destination::Discovered(pool.pick().to_owned());
1191 }
1192 HeavyProxy::Configured { asked } if asked.elapsed() < self.host_list_refresh => {
1193 return Destination::Configured(&self.base);
1194 }
1195 HeavyProxy::FellBack { until } if until.is_none_or(|until| Instant::now() < until) => {
1196 return Destination::Configured(&self.base);
1197 }
1198 HeavyProxy::Unasked | HeavyProxy::Configured { .. } | HeavyProxy::FellBack { .. } => {}
1199 }
1200
1201 // Only the first settle is worth a sentence: the re-ask after an
1202 // interval declining the same names would repeat it once a minute.
1203 let first_asking = matches!(&*resolved, HeavyProxy::Unasked);
1204
1205 match self.heavy_hosts() {
1206 // Every host this client is willing to use becomes the pool a
1207 // heavy command picks from. A name that is blank, malformed or
1208 // somewhere else entirely is passed over rather than being
1209 // allowed to stand for the whole answer — and, since a whole
1210 // answer passed over is the one failure that leaves routing
1211 // silently off, the reasons are said out loud once.
1212 Ok(hosts) => {
1213 let (usable, refused) = self.admitted(&hosts);
1214
1215 if usable.is_empty() {
1216 *resolved = HeavyProxy::Configured {
1217 asked: Instant::now(),
1218 };
1219 drop(resolved);
1220 if first_asking && !refused.is_empty() && self.retries.reports() {
1221 crate::observe::declined(&self.base, &refused);
1222 }
1223 return Destination::Configured(&self.base);
1224 }
1225
1226 let pool = HeavyPool {
1227 hosts: usable,
1228 fetched: Instant::now(),
1229 };
1230 let picked = pool.pick().to_owned();
1231 *resolved = HeavyProxy::Pool(pool);
1232 Destination::Discovered(picked)
1233 }
1234 // A failed lookup is never fatal: the command goes where it would
1235 // have gone before there was a lookup at all. Whether to ask again
1236 // *soon* is `worth_asking_again`, which is a different question
1237 // from whether to retry — a cluster with no `/hosts` endpoint
1238 // answers 404 every time and must not be asked before every
1239 // upload, while a timeout or a restarting proxy says nothing
1240 // about the roles and is worth one more question in a moment.
1241 // The settled verdict is re-examined an interval later either
1242 // way; permanence was the bug, not the memory.
1243 Err(error) => {
1244 *resolved = if crate::retry::worth_asking_again(&error) {
1245 HeavyProxy::FellBack {
1246 until: Instant::now().checked_add(self.hosts_retry_after),
1247 }
1248 } else {
1249 HeavyProxy::Configured {
1250 asked: Instant::now(),
1251 }
1252 };
1253 Destination::Configured(&self.base)
1254 }
1255 }
1256 }
1257
1258 /// One `/hosts` answer, split into the base URLs this client will use and
1259 /// the reasons for the names it will not — usable first, refusals second.
1260 fn admitted(&self, hosts: &[String]) -> (Vec<String>, Vec<String>) {
1261 let mut usable = Vec::new();
1262 let mut refused = Vec::new();
1263 for host in hosts {
1264 match heavy_base(&self.base, host, &self.hosts) {
1265 Ok(base) => usable.push(base),
1266 Err(why) => {
1267 refused.push(format!("{host:?} {}", why.because(&self.hosts, &self.base)));
1268 }
1269 }
1270 }
1271 (usable, refused)
1272 }
1273
1274 /// A fresh `/hosts` answer reduced to the base URLs this client will use.
1275 ///
1276 /// The refresh path: the refusals are neither collected nor said here —
1277 /// a refresh that declines what the first resolve declined would render
1278 /// the same sentences once a minute for the client's whole life, only to
1279 /// throw them away.
1280 fn usable_hosts(&self) -> Result<Vec<String>> {
1281 Ok(self
1282 .heavy_hosts()?
1283 .iter()
1284 .filter_map(|host| heavy_base(&self.base, host, &self.hosts).ok())
1285 .collect())
1286 }
1287
1288 /// The heavy proxies the cluster names, best first.
1289 ///
1290 /// `/hosts` answers with a JSON list of bare host names —
1291 /// `["n0008-sas.cluster-name", …]`, as the
1292 /// [HTTP proxy guide](https://ytsaurus.tech/docs/en/user-guide/proxy/http#upload)
1293 /// shows on the wire — "ordered by load … the very first proxy in the
1294 /// resulting list is the least loaded"
1295 /// ([reference](https://ytsaurus.tech/docs/en/user-guide/proxy/http-reference#hosts)).
1296 ///
1297 /// Which role it lists is *not* in the documentation. It is
1298 /// `default_role_filter`, a coordinator config parameter that
1299 /// `TCoordinatorConfig::Register` defaults to `NApi::DefaultHttpProxyRole`,
1300 /// which
1301 /// [`yt/yt/client/api/public.h`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/api/public.h)
1302 /// spells `"data"` — the role that serves heavy commands. A compiled-in
1303 /// default an operator can change, then, rather than a protocol guarantee,
1304 /// which is why this client checks what it is given instead of trusting the
1305 /// role.
1306 pub(crate) fn heavy_hosts(&self) -> Result<Vec<String>> {
1307 let body = self.fetch("/hosts", "hosts")?;
1308
1309 serde_json::from_str(&body).map_err(|e| ClientError::Decode {
1310 command: "hosts".to_owned(),
1311 reason: format!(
1312 "/hosts did not answer with a list of host names: {e}; body was {}",
1313 truncate(&body, 200)
1314 ),
1315 })
1316 }
1317
1318 /// What a heavy command's failure says about the proxy it was routed to.
1319 ///
1320 /// Two things, and both only for a command that actually went somewhere
1321 /// discovered — which is [`Destination`]'s to say, not something the
1322 /// address can be trusted to: `/hosts` may name the configured host
1323 /// itself, and a string comparison then read "routed there and failed" as
1324 /// "the caller's own choice", leaving a draining proxy in the pool for as
1325 /// long as it drained. A discovery-off client never takes the lock at
1326 /// all, and a failure at the *configured* address says nothing about a
1327 /// lookup: it was the caller who chose that one.
1328 ///
1329 /// **The error names the host.** `write_table: transport error: io:
1330 /// Connection refused` is a report about an address the caller never typed
1331 /// and cannot see. It now reads `write_table at n0132-sas.example.net:9013:
1332 /// …`.
1333 ///
1334 /// **A proxy a command failed at is dropped from the pool, and the next
1335 /// command picks from what remains.** The command itself is *not* sent
1336 /// again — heavy commands are not retried, and by this point a streaming
1337 /// body has been consumed anyway. This is about the next one. (A narrower
1338 /// gap survives on the streaming read path: [`Transport::open`] hands the
1339 /// body back unread, so a host that dies *mid-stream* fails in the
1340 /// caller's reader, past this seam, and stays in the pool until a request
1341 /// it answers at the head fails too.)
1342 ///
1343 /// It used to go back to the configured address for
1344 /// [`HOSTS_RETRY_AFTER`], and that is exactly the wrong address to go back
1345 /// to. On the deployment this feature was written for the configured
1346 /// address is a balancer in front of the *control* proxies, so one
1347 /// transient 503 from a draining data proxy — or one refused connection
1348 /// during a restart — turned into ten seconds of `Control proxy may not
1349 /// serve heavy requests with input data`, which is [#30] itself,
1350 /// reproducible on demand. `/hosts` had already named the alternatives.
1351 /// Now the failed host is dropped and the survivors carry the load, and
1352 /// only a pool with nobody left in it falls back — where falling back is
1353 /// at least a state that ends.
1354 ///
1355 /// Only for a failure **attributable to the host** it went to. A table
1356 /// that does not exist will not exist over there either, so a resolve
1357 /// error keeps the pool exactly as it was. But the predicate is
1358 /// [`crate::retry::attributable_to_the_host`], deliberately *not*
1359 /// [`crate::retry::worth_asking_again`]: the two agree except about a
1360 /// rejected certificate, and that disagreement was a real failure (#40).
1361 /// A certificate valid for every proxy but one — `NotValidForName` is a
1362 /// verdict about *this* host's name — answered "not worth asking the
1363 /// coordinator again", so the bad host was neither stepped past nor
1364 /// re-resolved, and every heavy command failed against it until the
1365 /// window elapsed and the same ordered-first host came back. Dropping a
1366 /// host must not require the lookup's own predicate to agree.
1367 ///
1368 /// [#30]: https://github.com/sshaplygin/ytsaurus-rs/issues/30
1369 fn after_heavy<T>(
1370 &self,
1371 repeatable: Repeatable,
1372 destination: &Destination<'_>,
1373 result: Result<T>,
1374 ) -> Result<T> {
1375 if repeatable != Repeatable::Heavy {
1376 return result;
1377 }
1378 let Err(error) = result else {
1379 return result;
1380 };
1381 if !self.discovery {
1382 return Err(refusal_hint(
1383 error,
1384 "this client does not route heavy commands: \
1385 Client::with_proxy_discovery(true) turns the /hosts lookup on",
1386 ));
1387 }
1388
1389 let base = match destination {
1390 // The command went to the configured address, which is the
1391 // caller's own choice and needs no explaining — unless what came
1392 // back is a proxy saying it will not serve this at all, which is
1393 // the failure routing exists to prevent and which says nothing
1394 // about routing being what was missing.
1395 Destination::Configured(_) => {
1396 let resolved = lock(&self.heavy);
1397 let why = declined_routing(&resolved);
1398 return Err(refusal_hint(error, why));
1399 }
1400 Destination::Discovered(base) => base,
1401 };
1402
1403 if crate::retry::attributable_to_the_host(&error) {
1404 let mut resolved = lock(&self.heavy);
1405 if let HeavyProxy::Pool(pool) = &mut *resolved
1406 && !pool.drop_host(base)
1407 {
1408 *resolved = HeavyProxy::FellBack {
1409 until: Instant::now().checked_add(self.hosts_retry_after),
1410 };
1411 }
1412 }
1413
1414 Err(routed_to(error, base))
1415 }
1416
1417 /// One attempt, read into memory.
1418 ///
1419 /// The cap on what is held is [`Transport::response_limit`] — the whole of
1420 /// why that is a field and not the constant read here.
1421 fn send(
1422 &self,
1423 base: &str,
1424 method: Method,
1425 command: &str,
1426 parameters: &YsonValue,
1427 payload: &Payload<'_>,
1428 ) -> Result<Vec<u8>> {
1429 // Held as bytes rather than as a `SendBody`, so a redirect can send the
1430 // same request again — see [`Outgoing`]. `None` is an empty slice and
1431 // not `Outgoing::Empty`, which is what it has always been on the wire:
1432 // `Content-Length: 0`.
1433 let body = match payload {
1434 Payload::None => Outgoing::Bytes(&[]),
1435 Payload::Bytes(bytes) => Outgoing::Bytes(bytes),
1436 };
1437 let mut response = self.dispatch(base, method, command, parameters, body, false)?;
1438
1439 let status = response.status().as_u16();
1440 let body = read_capped(command, response.body_mut(), self.response_limit)?;
1441
1442 if !(200..300).contains(&status) {
1443 return Err(ClientError::Http {
1444 command: command.to_owned(),
1445 status,
1446 body: truncate(&String::from_utf8_lossy(&body), 400),
1447 });
1448 }
1449
1450 Ok(body)
1451 }
1452
1453 /// Sends a command and hands back the response body **unread**.
1454 ///
1455 /// For `read_table`, whose response is the data: reading it into a `Vec`
1456 /// first would put a whole table in memory, which is the thing this avoids.
1457 ///
1458 /// Sent once, never retried, and sent to a heavy proxy — a response that is
1459 /// the data is the shape of a heavy command, and [`Repeatable::Heavy`] is
1460 /// all three of those facts at once.
1461 pub(crate) fn open(
1462 &self,
1463 method: Method,
1464 command: &str,
1465 parameters: &YsonValue,
1466 ) -> Result<ureq::Body> {
1467 let stamped = self.in_transaction(command, parameters);
1468 let parameters = stamped.as_ref().unwrap_or(parameters);
1469
1470 // Through `retry::run` like every other command, with `Repeatable::Heavy`
1471 // doing the sending-once: it caps the loop at one attempt and never
1472 // reaches the retry announcement, so this needs no second seam of its
1473 // own to be timed and named. The span closes when the headers arrive —
1474 // the reader handed back is read after that, at the caller's pace.
1475 let base = self.base_for(Repeatable::Heavy);
1476 let opened = crate::retry::run(self.retries, Repeatable::Heavy, command, |_| {
1477 let response = self.dispatch(
1478 base.address(),
1479 method,
1480 command,
1481 parameters,
1482 Outgoing::Empty,
1483 true,
1484 )?;
1485 let status = response.status().as_u16();
1486
1487 if !(200..300).contains(&status) {
1488 let mut response = response;
1489 let body = response.body_mut().read_to_string().unwrap_or_default();
1490 return Err(ClientError::Http {
1491 command: command.to_owned(),
1492 status,
1493 body: truncate(&body, 400),
1494 });
1495 }
1496
1497 Ok(response.into_body())
1498 });
1499
1500 self.after_heavy(Repeatable::Heavy, &base, opened)
1501 }
1502
1503 /// Sends a command whose request body is read as it goes, and returns the
1504 /// answer.
1505 ///
1506 /// For `write_table` from something larger than memory. `rows` is read
1507 /// once, so this cannot be retried even in principle: a reader that has
1508 /// been consumed cannot be sent again.
1509 ///
1510 /// The response body has to be read whatever the caller wants with it (see
1511 /// below), so it is handed back rather than dropped: `write_table` ignores
1512 /// it, and a raw command has no one but the caller to interpret it.
1513 pub(crate) fn upload(
1514 &self,
1515 method: Method,
1516 command: &str,
1517 parameters: &YsonValue,
1518 rows: &mut dyn std::io::Read,
1519 ) -> Result<Vec<u8>> {
1520 let stamped = self.in_transaction(command, parameters);
1521 let parameters = stamped.as_ref().unwrap_or(parameters);
1522
1523 // `Repeatable::Heavy` is the sending-once, as in `open`: one attempt,
1524 // no announcement, and the span comes from the seam every other command
1525 // already goes through. Unlike `open` it covers the whole transfer —
1526 // the body is read here, as it goes. It also picks the address: a
1527 // request whose body is a data stream is the one a control proxy
1528 // refuses by name.
1529 let base = self.base_for(Repeatable::Heavy);
1530 let sent = crate::retry::run(self.retries, Repeatable::Heavy, command, |_| {
1531 let mut response = self.dispatch(
1532 base.address(),
1533 method,
1534 command,
1535 parameters,
1536 Outgoing::Stream(&mut *rows),
1537 true,
1538 )?;
1539 let status = response.status().as_u16();
1540
1541 // Read whichever way it went. A body left unread keeps the
1542 // connection out of the pool — `ureq` can only reuse one it knows
1543 // is finished — so an upload that ignored its answer would open a
1544 // fresh connection for every table write, and leave the old one in
1545 // TIME_WAIT. The benchmark is what noticed: 11 623 of them after a
1546 // few seconds of writing.
1547 //
1548 // Read as bytes rather than as a string: an upload's answer is a
1549 // small structured document today, but a raw command sends whatever
1550 // it was given, and lossily decoding a binary answer would be a
1551 // silent corruption rather than a refusal.
1552 let body = match read_capped(command, response.body_mut(), self.response_limit) {
1553 Ok(body) => body,
1554 // An answer this client will not hold is the one failure worth
1555 // failing the write over. `raw_command_upload` hands this
1556 // `Vec` back as *the answer*, so an empty one would be the
1557 // same silent corruption reading it as a string would: a
1558 // command that returned half a gigabyte reported as one that
1559 // returned nothing.
1560 Err(error @ ClientError::ResponseTooLarge { .. }) => return Err(error),
1561 // Anything else is a cut or unreadable answer to a write whose
1562 // status line already said it was done. A heavy command is
1563 // sent once, so failing here fails a write that succeeded; the
1564 // body is read for the connection's sake, and what it said is
1565 // not worth that.
1566 Err(_) => Vec::new(),
1567 };
1568
1569 if !(200..300).contains(&status) {
1570 return Err(ClientError::Http {
1571 command: command.to_owned(),
1572 status,
1573 body: truncate(&String::from_utf8_lossy(&body), 400),
1574 });
1575 }
1576
1577 Ok(body)
1578 });
1579
1580 self.after_heavy(Repeatable::Heavy, &base, sent)
1581 }
1582
1583 /// Fetches a path that is not an API v4 command.
1584 ///
1585 /// `/hosts` is the only one, and it is not a command — but it wants most of
1586 /// what a command gets: the token, the guard that turns an `https://` proxy
1587 /// in a build without TLS into an explanation rather than a connection
1588 /// error, and the caller headers that say who is asking. Building a bare
1589 /// `ureq` request here instead is how it came to miss all of them.
1590 ///
1591 /// **The timeout and the retry policy are the exceptions**, and
1592 /// deliberately: this question has its own budget. One attempt bounded by
1593 /// [`HOSTS_TIMEOUT`], not five of up to two minutes with fifteen seconds of
1594 /// backoff — because a heavy command is *waiting* on the answer, holding
1595 /// the lock every other heavy command wants, and not getting one costs
1596 /// nothing worse than the routing this client had none of a release ago.
1597 /// A lookup worth repeating is repeated by the next heavy command after
1598 /// [`HOSTS_RETRY_AFTER`], which is the same retry spread out where it does
1599 /// not queue anybody.
1600 ///
1601 /// It goes to the **configured** address whatever it is asking about: the
1602 /// question `/hosts` answers is where the other addresses are.
1603 ///
1604 /// It follows a **same-origin** redirect like any command (#36) — a
1605 /// balancer canonicalising its own `/hosts` URL — but a cross-origin one is
1606 /// refused with [`ClientError::Redirected`], which the router treats as
1607 /// worth asking again rather than a permanent verdict.
1608 pub(crate) fn fetch(&self, path: &str, what: &str) -> Result<String> {
1609 if let Some(error) = self.unusable(&self.base) {
1610 return Err(error);
1611 }
1612
1613 let first = format!("{}{path}", self.base);
1614
1615 // One attempt (#38): `/hosts` is not retried by the retry loop — the
1616 // retry is HOSTS_RETRY_AFTER, spread across later heavy commands. The
1617 // budget is the lookup's own (#38, HOSTS_TIMEOUT), shared across the
1618 // redirect hops this may follow (#36) rather than handed to each.
1619 crate::retry::run(RetryPolicy::none(), Repeatable::Freely, what, |_| {
1620 let mut url = first.clone();
1621 let mut hops = 0;
1622 let deadline = Instant::now().checked_add(self.hosts_timeout);
1623
1624 // A same-origin redirect is followed — a balancer canonicalising
1625 // its own `/hosts` URL — and a cross-origin one is refused with
1626 // `ClientError::Redirected`, which the router treats as worth
1627 // asking again. The loop ends because `redirect` refuses past
1628 // MAX_REDIRECTS, or sooner because the budget runs out.
1629 let mut response = loop {
1630 let left = remaining(deadline, what)?;
1631 let response =
1632 with_headers!(self.scoped(self.agent.get(&url), false, left), &self.caller)
1633 .call()
1634 .map_err(|e| ClientError::Transport {
1635 command: what.to_owned(),
1636 source: Box::new(e),
1637 })?;
1638
1639 match self.redirect(what, &response, &url, &Outgoing::Empty, hops)? {
1640 Some(next) => {
1641 if let Some(error) = self.unusable(&next) {
1642 return Err(error);
1643 }
1644 url = next;
1645 hops += 1;
1646 }
1647 None => break response,
1648 }
1649 };
1650
1651 let status = response.status().as_u16();
1652 let body = response
1653 .body_mut()
1654 .read_to_string()
1655 // As in `send`: a body cut off by the network must stay
1656 // retriable, and `Decode` is not.
1657 .map_err(|e| ClientError::Transport {
1658 command: what.to_owned(),
1659 source: Box::new(e),
1660 })?;
1661
1662 if !(200..300).contains(&status) {
1663 return Err(ClientError::Http {
1664 command: what.to_owned(),
1665 status,
1666 body: truncate(&body, 400),
1667 });
1668 }
1669
1670 Ok(body)
1671 })
1672 }
1673
1674 /// Builds and sends one request, and checks the cluster's own error header.
1675 ///
1676 /// Everything past this point differs only in how the response body is
1677 /// consumed.
1678 ///
1679 /// `streaming` lifts the agent's end-to-end timeout for this request: a
1680 /// table moves through [`Transport::open`] and [`Transport::upload`] for as
1681 /// long as it takes, and a deadline sized for control commands would cut
1682 /// the transfer off mid-table. The waits that precede the data — resolve,
1683 /// connect, sending the request, the response headers — each stay bounded
1684 /// by the same timeout, so a dead proxy still fails promptly; only the
1685 /// body itself is open-ended.
1686 ///
1687 /// **A buffered command's timeout is end to end across the redirects too.**
1688 /// The deadline is taken once, here, and every hop is given what is left of
1689 /// it rather than a fresh copy — which is what `ureq` did while it was the
1690 /// one following them, `Timeout::Global` covering the whole chain. Handing
1691 /// each hop the full timeout instead would make the real limit
1692 /// `(MAX_REDIRECTS + 1)` times the one the caller asked for: eleven times
1693 /// two minutes for a balancer that points at itself, on a call that
1694 /// promised two.
1695 ///
1696 /// `base` is where this one goes — the configured address for a light
1697 /// command, and whatever [`Transport::base_for`] resolved for a heavy one.
1698 fn dispatch(
1699 &self,
1700 base: &str,
1701 method: Method,
1702 command: &str,
1703 parameters: &YsonValue,
1704 mut body: Outgoing<'_>,
1705 streaming: bool,
1706 ) -> Result<ureq::http::Response<ureq::Body>> {
1707 // Judged against `base` — the address this request is actually dialled
1708 // at, which for a heavy command is the one `/hosts` named (#38) — not
1709 // `self.base`. A no-TLS build must refuse an `https://` heavy proxy as
1710 // surely as an `https://` configured one, and a refused CA bundle bites
1711 // it too. `unusable` takes the address for that reason.
1712 if let Some(error) = self.unusable(base) {
1713 return Err(error);
1714 }
1715
1716 // `mut` because a same-origin redirect (#36) reassigns it below.
1717 let mut url = format!("{base}/api/v4/{command}");
1718
1719 let encoded = to_string(parameters, YsonFormat::Text).map_err(|e| ClientError::Decode {
1720 command: command.to_owned(),
1721 reason: format!("could not encode parameters: {e}"),
1722 })?;
1723
1724 // What is being asked. Who is asking is `self.caller`, applied beside
1725 // this rather than concatenated onto it: those headers are already
1726 // rendered, and copying them into a fresh `Vec` per request is the
1727 // allocation this avoids.
1728 let headers: [(&str, String); 4] = [
1729 (HEADER_FORMAT, "<format=text>yson".to_owned()),
1730 (PARAMETERS, encoded),
1731 ("X-YT-Output-Format", "<format=text>yson".to_owned()),
1732 ("Content-Type", "application/octet-stream".to_owned()),
1733 ];
1734
1735 // Taken once for the attempt, not once per hop. See the note above.
1736 let deadline = self.deadline(streaming);
1737 // The loop ends because `redirect` refuses past [`MAX_REDIRECTS`], and
1738 // sooner than that because the deadline runs out.
1739 let mut hops = 0;
1740
1741 loop {
1742 let left = remaining(deadline, command)?;
1743
1744 // The method survives the hop, whatever the digit: `307` and `308`
1745 // require it, and an API v4 command's verb belongs to the command
1746 // — the reference derives it from whether the command mutates and
1747 // whether it has an input stream, neither of which a `Location`
1748 // changes. So does the body, when there is one that can be sent
1749 // again; `redirect` refuses the hop when there is not.
1750 let sent = match method {
1751 // A GET carries no body in `ureq`'s type system, which is also
1752 // true of every command this client sends as one.
1753 Method::Get => with_headers!(
1754 self.scoped(self.agent.get(&url), streaming, left),
1755 &headers,
1756 &self.caller
1757 )
1758 .call(),
1759 // `post` and `put` build the same request type, so the body is
1760 // chosen once for both. A fresh `SendBody` per hop rather than
1761 // one taken out of an `Option`: that is what lets the same
1762 // request go out twice, and `SendBody` cannot be reused.
1763 Method::Post | Method::Put => {
1764 let request = with_headers!(
1765 self.scoped(
1766 match method {
1767 Method::Put => self.agent.put(&url),
1768 _ => self.agent.post(&url),
1769 },
1770 streaming,
1771 left
1772 ),
1773 &headers,
1774 &self.caller
1775 );
1776
1777 match &mut body {
1778 Outgoing::Empty => request.send(SendBody::none()),
1779 Outgoing::Bytes(bytes) => request.send(*bytes),
1780 Outgoing::Stream(reader) => {
1781 request.send(SendBody::from_reader(&mut **reader))
1782 }
1783 }
1784 }
1785 };
1786
1787 let response = sent.map_err(|e| ClientError::Transport {
1788 command: command.to_owned(),
1789 source: Box::new(e),
1790 })?;
1791
1792 // Before the cluster's own error, because a redirect is not the
1793 // cluster reporting a failure — it is this client deciding where a
1794 // request goes, which is a fact no `X-YT-Error` could carry.
1795 if let Some(next) = self.redirect(command, &response, &url, &body, hops)? {
1796 // The same guard the first address got: a same-origin redirect
1797 // cannot change the scheme, but nothing here assumes that.
1798 if let Some(error) = tls_unavailable(&next) {
1799 return Err(error);
1800 }
1801 url = next;
1802 hops += 1;
1803 continue;
1804 }
1805
1806 // The cluster's own error, which is far more useful than the status.
1807 if let Some(raw) = header_value(response.headers(), ERROR) {
1808 return Err(ClientError::from_yt_error(
1809 command,
1810 response.status().as_u16(),
1811 &raw,
1812 ));
1813 }
1814
1815 return Ok(response);
1816 }
1817 }
1818
1819 /// What becomes of a `3xx`: `Ok(Some(url))` to go there, `Ok(None)` to
1820 /// treat the response as an ordinary one, `Err` to refuse.
1821 ///
1822 /// A control proxy does not refuse a heavy *read*. It answers `307
1823 /// Temporary Redirect` naming a data proxy on a **different host** — the
1824 /// [HTTP proxy reference](https://ytsaurus.tech/docs/en/user-guide/proxy/http-reference#return_codes)
1825 /// lists that code as *"Redirecting heavy queries from light to heavy
1826 /// proxies"*:
1827 ///
1828 /// ```text
1829 /// HTTP/1.1 307 Temporary Redirect
1830 /// Location: http://data-proxy-01.example.net:80/api/v4/read_table?path=…
1831 /// ```
1832 ///
1833 /// `ureq` would follow that by default and, also by default
1834 /// (`RedirectAuthHeaders::Never`), drop the `Authorization` header on the
1835 /// way. The second request therefore arrives unauthenticated and the
1836 /// cluster answers `Client is missing credentials` — about a token that is
1837 /// perfectly valid. The user then checks the token, the token file and
1838 /// their permissions, none of which is at fault.
1839 ///
1840 /// **`redirect_auth_headers(RedirectAuthHeaders::SameHost)` is not the
1841 /// answer**, though it is the first thing that suggests itself and reads
1842 /// like the setting this was missing. It re-attaches the header only when
1843 /// the redirect stays on the same host and under https; this redirect is
1844 /// deliberately cross-host, control proxy to data proxy, so the header
1845 /// would be dropped exactly as before — and the next reader would conclude
1846 /// the problem lay somewhere else entirely.
1847 ///
1848 /// So the rules are here instead, and there are four of them. Three say
1849 /// what a redirect must not take with it across an origin, and the fourth
1850 /// says when a route stops being one.
1851 ///
1852 /// **A redirect that leaves the origin is refused when the request carries
1853 /// credentials.** That leaves the honest choice — re-attach for the host
1854 /// the *proxy* named, or go nowhere — settled at "go nowhere". A
1855 /// `Location` arrives mid-flight, on a request addressed somewhere else;
1856 /// asking `/hosts` and addressing the answer is a question this client put
1857 /// deliberately, before the request was built. Same origin, and it is
1858 /// followed: nothing new learns the token by it, and a balancer
1859 /// canonicalising its own host would otherwise break every command.
1860 ///
1861 /// **A redirect on a body this client cannot send again is refused.** Not
1862 /// on a body: on an *unrepeatable* one, and wherever it points. Following a
1863 /// redirect here means sending the same request to the address it named —
1864 /// same method, same payload — which is what `307` and `308` require and
1865 /// what an API v4 command needs whatever the digit, since a command's verb
1866 /// is a property of the command. A payload held as bytes goes out again and
1867 /// nothing is lost. A payload that is a *reader* — [`Transport::upload`],
1868 /// so `write_table` from an iterator and every `raw_command_upload` — has
1869 /// already begun to drain into the first request by the time the `3xx`
1870 /// arrives, and cannot be rewound. That one is refused, with or without a
1871 /// token: dropping the rows and reporting the answer to an empty request
1872 /// is how a write that wrote nothing comes back looking like one that
1873 /// worked.
1874 ///
1875 /// **A redirect that leaves the origin is refused when the request carries
1876 /// data**, whether or not there is a token. This is the credentials rule
1877 /// again, about the other thing a caller chooses a host for: a token is not
1878 /// the only thing worth not handing to a host nobody named, and a table's
1879 /// rows are the caller's own. Sending them on would answer a header that
1880 /// arrived mid-flight with the contents of the request. A body of length
1881 /// zero is not data — `Content-Length: 0` gives nothing away — so a
1882 /// bodiless `POST` still goes wherever the credentials rule lets it.
1883 ///
1884 /// **A chain that does not end is refused.** [`MAX_REDIRECTS`] hops, then
1885 /// it is a loop rather than a route.
1886 ///
1887 /// The order is the order of what a caller most needs told. Credentials
1888 /// first, because a leaked token is the worst outcome and a refused one is
1889 /// the confusing one. Then the unrepeatable body, because that is refused
1890 /// at any address and so is the more general fact about the request. Then
1891 /// the data crossing an origin, which is the one a same-origin balancer
1892 /// never triggers.
1893 ///
1894 /// The deliberate way to reach a data proxy is to ask the cluster for one
1895 /// — `/hosts`, [`Client::heavy_proxy`](crate::Client::heavy_proxy) — and
1896 /// address it on purpose. Routing heavy commands there is what removes the
1897 /// redirect altogether; this is the half that holds when something is
1898 /// redirected anyway.
1899 ///
1900 /// A `3xx` that names no `Location`, or one this client cannot resolve
1901 /// into an address, is not a redirect that was refused — it is a proxy
1902 /// answering something odd, and it stays an ordinary
1903 /// [`ClientError::Http`].
1904 fn redirect(
1905 &self,
1906 command: &str,
1907 response: &ureq::http::Response<ureq::Body>,
1908 request_url: &str,
1909 body: &Outgoing<'_>,
1910 hops: usize,
1911 ) -> Result<Option<String>> {
1912 let status = response.status();
1913 if !status.is_redirection() {
1914 return Ok(None);
1915 }
1916
1917 let Some(location) = header_value(response.headers(), LOCATION) else {
1918 return Ok(None);
1919 };
1920 // Resolved before anything is decided about it, so the origin
1921 // comparison has an origin to work with and the message names a host
1922 // even when the proxy sent `Location: /api/v4/…`.
1923 let Some(target) = resolve(request_url, &location) else {
1924 return Ok(None);
1925 };
1926
1927 let refused = |refusal| {
1928 Err(ClientError::Redirected {
1929 command: command.to_owned(),
1930 status: status.as_u16(),
1931 location: target.clone(),
1932 refusal,
1933 heavy: is_heavy(command),
1934 })
1935 };
1936
1937 // Computed once: both origin rules ask the same question, and it is
1938 // the expensive one here.
1939 let elsewhere = !same_origin(request_url, &target);
1940
1941 // Credentials first: it is the one a caller most needs the reason for,
1942 // and the one a heavy `write_table` would otherwise be told the wrong
1943 // thing about.
1944 if self.token.is_some() && elsewhere {
1945 return refused(RedirectRefusal::Credentials);
1946 }
1947 if !body.replayable() {
1948 return refused(RedirectRefusal::Body);
1949 }
1950 // A token is not the only thing a caller picks a host for. Without
1951 // this, a tokenless `write_table` answered `302` sent its rows to
1952 // whichever host the header named — which is not the silent nothing
1953 // the rule above prevents, but it is still the request's contents
1954 // going somewhere nobody asked for.
1955 if elsewhere && body.carries_data() {
1956 return refused(RedirectRefusal::Payload);
1957 }
1958 if hops >= MAX_REDIRECTS {
1959 return refused(RedirectRefusal::TooMany);
1960 }
1961
1962 Ok(Some(target))
1963 }
1964
1965 /// The headers that say who is asking rather than what is being asked.
1966 ///
1967 /// One place for both, because they belong to every request and not to any
1968 /// command: `/hosts` is not a command and still wants them. Building its
1969 /// request separately is how it once came to carry no token at all — see
1970 /// [`Transport::fetch`].
1971 ///
1972 /// The trace context is sent on every attempt of a retried command, with
1973 /// the same span id each time. That is deliberate: the retries are the
1974 /// same logical call, and the cluster's spans for them belong under the
1975 /// one span the caller knows about.
1976 ///
1977 /// Rendered when the transport is built or its trace is set, and not once
1978 /// per request: every value here is fixed for the transport's lifetime, so
1979 /// re-`format!`ing the token and re-cloning the trace for each attempt of
1980 /// each command bought nothing. The row-by-row write path and the
1981 /// two-second `wait_for_operation` poll are the ones that noticed.
1982 fn render_caller_headers(&mut self) {
1983 let mut headers = Vec::new();
1984 if let Some(token) = &self.token {
1985 headers.push(("Authorization", format!("OAuth {token}")));
1986 }
1987 if let Some(trace) = &self.trace {
1988 headers.push((TRACEPARENT, trace.clone()));
1989 }
1990 // Passed on beside `traceparent` and never without it: the standard
1991 // pairs the two, and a `tracestate` sent alone names no trace.
1992 if let (Some(_), Some(state)) = (&self.trace, &self.tracestate) {
1993 headers.push((TRACESTATE, state.clone()));
1994 }
1995 self.caller = headers;
1996 }
1997
1998 /// Why no request can be sent at all, if that was settled before any was.
1999 ///
2000 /// Two reasons, and both are about TLS rather than about the network: the
2001 /// crate was built without the `tls` feature and the proxy is `https://`,
2002 /// or [`CA_BUNDLE`] named something that could not be turned into root
2003 /// certificates. Reported here so the caller reads a sentence naming the
2004 /// cause instead of a handshake failure that explains nothing.
2005 ///
2006 /// A refused bundle only bites an `https://` proxy: over plain HTTP there
2007 /// is no handshake for it to have configured, and a stale variable left in
2008 /// an environment whose cluster is local costs nothing.
2009 ///
2010 /// `base` is the address the request is about to be dialled at, not
2011 /// necessarily [`Transport::base`]: a heavy command goes wherever `/hosts`
2012 /// named (#38), and both refusals are properties of *that* address rather
2013 /// than of the configured one. [`heavy_base`] derives the scheme from the
2014 /// configured address, so the two usually agree — but the parameter is what
2015 /// makes a discovered `https://` heavy proxy refused by a no-TLS build or a
2016 /// broken bundle, which a `self.base` check would wave through.
2017 fn unusable(&self, base: &str) -> Option<ClientError> {
2018 if let Some(error) = tls_unavailable(base) {
2019 return Some(error);
2020 }
2021
2022 match &self.tls_refused {
2023 Some(why) if base.starts_with("https://") => Some(ClientError::Config(why.clone())),
2024 _ => None,
2025 }
2026 }
2027
2028 /// When one attempt of a command must be finished by.
2029 ///
2030 /// `None` for a streaming transfer, which is bounded per phase instead —
2031 /// and for a timeout so large that no `Instant` can express its deadline,
2032 /// where the agent's own `timeout_global` is left to do the bounding.
2033 fn deadline(&self, streaming: bool) -> Option<Instant> {
2034 if streaming {
2035 return None;
2036 }
2037 Instant::now().checked_add(self.timeout)
2038 }
2039
2040 /// Bounds one request: what is left of the command's deadline, or the
2041 /// per-phase limits a streaming transfer gets instead.
2042 ///
2043 /// For a streaming request the end-to-end deadline comes off and every
2044 /// phase before the data — DNS, connect, sending the request, waiting for
2045 /// the response headers — keeps the same bound individually. For a
2046 /// buffered one `left` is the remainder of the deadline taken in
2047 /// [`Transport::dispatch`], so a redirect chain spends one budget between
2048 /// its hops rather than one apiece.
2049 fn scoped<Any>(
2050 &self,
2051 request: ureq::RequestBuilder<Any>,
2052 streaming: bool,
2053 left: Option<Duration>,
2054 ) -> ureq::RequestBuilder<Any> {
2055 if !streaming {
2056 return match left {
2057 Some(left) => request.config().timeout_global(Some(left)).build(),
2058 // No deadline to share out — the agent's own global timeout
2059 // still applies.
2060 None => request,
2061 };
2062 }
2063 request
2064 .config()
2065 .timeout_global(None)
2066 .timeout_resolve(Some(self.timeout))
2067 .timeout_connect(Some(self.timeout))
2068 .timeout_send_request(Some(self.timeout))
2069 .timeout_recv_response(Some(self.timeout))
2070 .build()
2071 }
2072}
2073
2074/// Takes the lock, and takes it back from a thread that panicked holding it.
2075///
2076/// What this guards is a cached address. A panic while resolving one leaves it
2077/// as it was — `Unasked`, or the answer from before — and none of that is worth
2078/// poisoning a client over.
2079fn lock(heavy: &Mutex<HeavyProxy>) -> MutexGuard<'_, HeavyProxy> {
2080 heavy
2081 .lock()
2082 .unwrap_or_else(std::sync::PoisonError::into_inner)
2083}
2084
2085/// Turns a host from `/hosts` into a base URL, or refuses it.
2086///
2087/// `/hosts` answers with **bare host names** —
2088/// `["n0008-sas.cluster-name", …]`, as the
2089/// [HTTP proxy guide](https://ytsaurus.tech/docs/en/user-guide/proxy/http#upload)
2090/// shows on the wire. Everything else about the address is this client's to
2091/// decide, and every one of those decisions is a place a forged or mistaken
2092/// `/hosts` body could send an upload — and the OAuth token with it — somewhere
2093/// the caller never named. So a name is checked rather than pasted:
2094///
2095/// - **the scheme comes from the configured address and only from there.** A
2096/// host naming its own is refused outright, which is what closes the
2097/// downgrade: `http://n0132` from an `https://` client used to strip TLS and
2098/// put the token on the wire in cleartext. A cluster reached over TLS serves
2099/// its heavy commands over TLS.
2100/// - **`/`, `@`, `://` and whitespace are refused.** `@` is the one that
2101/// matters: `real.example.net@evil.example.net` is a URL whose *host* is
2102/// `evil.example.net` and whose userinfo is the reassuring half.
2103/// - **the configured port carries through** when the name has none, because
2104/// the name usually has none — the coordinator only appends `:port` when its
2105/// `ShowPorts` config says to — and a client reached at `:8443` has no reason
2106/// to believe the heavy proxies answer on 80.
2107/// - **the name has to be one host and at most one port.** A bare IPv6 literal
2108/// is not a valid URL authority; bracketed, it is — and brackets around
2109/// anything that is *not* an IPv6 literal are worse than a refusal, because
2110/// `ureq` 3.3 hands them to the resolver unchanged. Probed:
2111/// `https://[n0132.example.com]evil.attacker.com` parses with the host
2112/// `[n0132.example.com]`, which no DNS will ever answer, so the entry cost
2113/// nothing but a permanently failing address.
2114/// - **the name must sit where `allowed` says**, which is the configured
2115/// address's own domain by default. See below.
2116///
2117/// # What the domain rule is worth, and what it is not
2118///
2119/// It was written down here as "the token cannot go somewhere you did not
2120/// name", and that is more than it does. To steer the token with a `/hosts`
2121/// body you must control that body: over `https://` that means owning the
2122/// proxy, which already has the token, and over `http://` it means being a
2123/// man-in-the-middle, who reads the token out of every light command without
2124/// touching this code path at all. The one threat model where the rule bites is
2125/// a proxy **registering itself** in the cluster's coordinator under a name the
2126/// operators did not intend.
2127///
2128/// And there it is a coarse instrument, because it is a suffix rule and cannot
2129/// be anything else without a public-suffix list — a dependency deliberately
2130/// not taken. `yt-prod.westeurope.cloudapp.azure.com` admits every Azure VM in
2131/// the region; `yt-1234.us-east-1.elb.amazonaws.com` admits every ELB in it.
2132/// So read this as **a guard against a typo in a configuration and against an
2133/// obviously foreign domain**, not as a boundary that holds a credential.
2134/// `HeavyHosts::Only` — `Client::with_heavy_proxies_in` — is the boundary,
2135/// because it is a list somebody wrote on purpose.
2136///
2137/// The rule itself: the name must **be** the configured host, or sit under the
2138/// configured host's parent domain — its own name minus the leftmost label,
2139/// never shortened below two labels. `cluster.example.net` therefore admits
2140/// `n0132-sas.example.net` and `n0132-sas.cluster.example.net`, and refuses
2141/// `cluster.example.net.evil.com`. A **bare cluster name** — `YT_PROXY=hume`,
2142/// which is the commonest spelling there is — has no parent domain, and is
2143/// matched as a label instead; see [`same_domain`]. An address that is a
2144/// literal IP admits only itself: an IP has no domain to share.
2145///
2146/// A refused name is passed over, and a cluster whose whole answer is refused
2147/// is treated as one that named nobody — the upload goes to the configured
2148/// address, which is where it went before there was a lookup at all, and
2149/// [`crate::observe::declined`] says so once rather than leaving it to be
2150/// deduced from a cluster error much later.
2151fn heavy_base(
2152 configured: &str,
2153 host: &str,
2154 allowed: &HeavyHosts,
2155) -> std::result::Result<String, Declined> {
2156 let host = host.trim();
2157
2158 if host.is_empty()
2159 || host.contains("://")
2160 || host.contains('/')
2161 || host.contains('@')
2162 || host.contains(['?', '#'])
2163 || host.chars().any(char::is_whitespace)
2164 || !is_authority(host)
2165 {
2166 return Err(Declined::Malformed);
2167 }
2168
2169 if !allowed.admits(configured, host) {
2170 return Err(Declined::Elsewhere);
2171 }
2172
2173 let scheme = if configured.starts_with("https://") {
2174 "https://"
2175 } else {
2176 "http://"
2177 };
2178
2179 Ok(match (has_port(host), port_of(configured)) {
2180 (false, Some(port)) => format!("{scheme}{host}:{port}"),
2181 _ => format!("{scheme}{host}"),
2182 })
2183}
2184
2185/// Whether a name from `/hosts` is one host and at most one port.
2186///
2187/// Bracketed, it has to hold an **IPv6 literal**: the brackets are what make a
2188/// literal an authority, and they are not decoration a name may wear. `ureq`
2189/// 3.3 does not strip them from anything else, so `[n0132.example.com]evil` was
2190/// accepted here and then failed to resolve for as long as the client lived —
2191/// every heavy command failing on DNS, the address kept, and the failures
2192/// repeating without end.
2193///
2194/// Unbracketed, a colon introduces a port and a port is digits. That also
2195/// refuses the bare IPv6 literal, which is the same rule seen from the other
2196/// side: `2a02:6b8::2` is not a host and a port.
2197fn is_authority(host: &str) -> bool {
2198 match host.strip_prefix('[') {
2199 Some(rest) => match rest.split_once(']') {
2200 Some((literal, tail)) => {
2201 literal.parse::<std::net::Ipv6Addr>().is_ok()
2202 && (tail.is_empty() || tail.strip_prefix(':').is_some_and(is_port))
2203 }
2204 None => false,
2205 },
2206 None => match host.split_once(':') {
2207 Some((name, port)) => !name.is_empty() && is_port(port),
2208 None => true,
2209 },
2210 }
2211}
2212
2213/// Whether what follows a colon is a port and nothing else.
2214fn is_port(port: &str) -> bool {
2215 !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())
2216}
2217
2218/// Whether `discovered` sits under the same domain as `configured`.
2219///
2220/// The domain both must share is the configured host minus its leftmost label,
2221/// never shortened below two labels — so a client pointed at
2222/// `cluster.example.net` accepts anything under `example.net`, and one pointed
2223/// at the two-label `example.net` accepts only `example.net` and what is under
2224/// it. See [`heavy_base`] for what this rule is worth, which is less than it
2225/// was once written down as.
2226///
2227/// # A bare cluster name has no parent domain
2228///
2229/// `YT_PROXY=hume` is not an edge case, it is the ordinary spelling: a cluster
2230/// name with no dots in it, which `Transport::new` supports on purpose by
2231/// putting `https://` in front. Such a name has nothing to take a leftmost
2232/// label off, so the parent-domain rule degenerates to "the name itself" —
2233/// and then a `/hosts` answering `["n0008-sas.hume.yt.example.net"]`, which is
2234/// the real shape of a real installation, is refused **entirely and
2235/// permanently**: the state settles as `Configured`, the lookup is never
2236/// repeated, and every upload goes back to being refused by a control proxy
2237/// with nothing anywhere to say why. The same break waits in Kubernetes for
2238/// anyone who addresses the service by its short name.
2239///
2240/// So a configured name with no dot is matched as a **label** of the discovered
2241/// name, and not as its leftmost one: `hume` admits
2242/// `n0008-sas.hume.yt.example.net` and `n0008-sas.hume`, and refuses
2243/// `hume.evil.com` — where the cluster's name has been put in the position a
2244/// *host* name occupies rather than the position a domain does.
2245///
2246/// A literal IP address has no domain, so it admits only itself.
2247fn same_domain(configured: &str, discovered: &str) -> bool {
2248 let configured = configured.to_ascii_lowercase();
2249 let discovered = discovered.to_ascii_lowercase();
2250
2251 if configured == discovered {
2252 return true;
2253 }
2254 if configured.parse::<std::net::IpAddr>().is_ok()
2255 || discovered.parse::<std::net::IpAddr>().is_ok()
2256 {
2257 return false;
2258 }
2259
2260 let domain = match configured.split_once('.') {
2261 // Its parent domain, never shortened below two labels.
2262 Some((_, parent)) if parent.contains('.') => parent,
2263 Some(_) => configured.as_str(),
2264 // A bare cluster name: a label of the discovered name, and not the
2265 // leftmost one, which is where the proxy's own name goes.
2266 None => {
2267 return discovered
2268 .split('.')
2269 .skip(1)
2270 .any(|label| label == configured);
2271 }
2272 };
2273
2274 discovered == domain || discovered.ends_with(&format!(".{domain}"))
2275}
2276
2277/// Whether `discovered` sits under a domain the caller added by hand.
2278///
2279/// The plain suffix rule, and deliberately not [`same_domain`]'s: there the
2280/// domain has to be *derived* from a host name, and the leftmost-label and
2281/// bare-label cases exist because `YT_PROXY` is a host and not a domain. Here
2282/// the caller wrote a domain down, so it is used as one — `proxy-zone.net`
2283/// admits `n0132-sas.rack7.proxy-zone.net` and itself, and nothing
2284/// else.
2285///
2286/// `domain` is already trimmed, lowercased and stripped of stray dots by
2287/// [`Transport::set_heavy_proxies_under`], and is never empty.
2288///
2289/// This does not make the rule a boundary — the suffix caveat in [`heavy_base`]
2290/// applies to a domain somebody typed exactly as it applies to one that was
2291/// derived, and `HeavyHosts::Only` is still the version that is a boundary. What
2292/// it does is stop "the rule missed by one label" from having to be answered by
2293/// removing the rule.
2294fn under_domain(domain: &str, discovered: &str) -> bool {
2295 let discovered = discovered.to_ascii_lowercase();
2296
2297 discovered == domain || discovered.ends_with(&format!(".{domain}"))
2298}
2299
2300/// Whether an authority names a port of its own.
2301fn has_port(authority: &str) -> bool {
2302 match authority.split_once(']') {
2303 Some((_, rest)) => rest.starts_with(':'),
2304 None => authority.contains(':'),
2305 }
2306}
2307
2308/// The port out of a base URL, if it names one.
2309fn port_of(base: &str) -> Option<&str> {
2310 let authority = authority_of(base);
2311 let port = match authority.split_once(']') {
2312 Some((_, rest)) => rest.strip_prefix(':')?,
2313 None => authority.split_once(':').map(|(_, port)| port)?,
2314 };
2315 (!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())).then_some(port)
2316}
2317
2318/// The `host:port` out of a base URL — what a failure should name.
2319///
2320/// Userinfo comes off, which matters twice: it is where a password would be,
2321/// and leaving it on would make [`port_of`] read `pass@host:8000` and find no
2322/// port at all.
2323fn authority_of(base: &str) -> &str {
2324 let authority = base
2325 .split_once("://")
2326 .map_or(base, |(_, rest)| rest)
2327 .split(['/', '?', '#'])
2328 .next()
2329 .unwrap_or_default();
2330 authority.rsplit_once('@').map_or(authority, |(_, h)| h)
2331}
2332
2333/// Why a heavy command was served at the configured address after all.
2334///
2335/// One short clause per state, for [`refusal_hint`] to hang on the cluster's
2336/// own refusal. Each names the builder that changes the answer, because the
2337/// refusal itself names nothing: an operator reading `Control proxy may not
2338/// serve heavy requests with input data` has no way to learn from it that this
2339/// client asked `/hosts`, got a perfectly good name and declined it.
2340fn declined_routing(state: &HeavyProxy) -> &'static str {
2341 match state {
2342 HeavyProxy::Configured { .. } => {
2343 "/hosts named no heavy proxy this client would use — \
2344 Client::with_heavy_proxies_under([…]) or YT_HEAVY_PROXY_DOMAINS \
2345 names the domain they are in, Client::with_heavy_proxies_in([…]) \
2346 names the proxies themselves, and \
2347 Client::with_heavy_proxies_anywhere(true) or \
2348 YT_HEAVY_PROXIES_ANYWHERE=1 allows any name it refused"
2349 }
2350 HeavyProxy::FellBack { .. } => {
2351 "the heavy proxies /hosts named have all just failed, \
2352 so this went to the configured address for a moment"
2353 }
2354 HeavyProxy::Unasked | HeavyProxy::Pool(_) => "this client did not route this command",
2355 }
2356}
2357
2358/// Adds the sentence a control proxy's refusal does not carry.
2359///
2360/// Only for that one refusal, which is the only failure here that is about
2361/// *which proxy was asked* — see [`CONTROL_REFUSAL`]. Everything else is about
2362/// the request, and a hint about routing beside it would be noise.
2363///
2364/// Appended to the message rather than wrapped in a new variant: the caller
2365/// wants the cluster's own words *and* the one fact the cluster cannot know,
2366/// and a second error type would make the first harder to match on for the sake
2367/// of the second.
2368fn refusal_hint(error: ClientError, why: &str) -> ClientError {
2369 match error {
2370 ClientError::Cluster {
2371 command,
2372 code,
2373 message,
2374 raw,
2375 } if message.contains(CONTROL_REFUSAL) => ClientError::Cluster {
2376 command,
2377 code,
2378 message: format!("{message} ({why})"),
2379 raw,
2380 },
2381 other => other,
2382 }
2383}
2384
2385/// The response cap, applied where the bytes actually accumulate.
2386///
2387/// `ureq`'s own `limit()` counts what arrives on the wire; this counts what
2388/// comes out of the decoder, which is what is held. [`RESPONSE_LIMIT`] has the
2389/// measurement that makes the difference a factor of a thousand rather than a
2390/// technicality.
2391///
2392/// It reads one byte past what is left, so an overrun is *visible* without
2393/// being kept, and fails with the same `ureq::Error::BodyExceedsLimit` the
2394/// wire limit raises — so both arrive at [`body_failure`] as one case with one
2395/// message.
2396///
2397/// **A body of exactly `limit` decoded bytes passes**, which is the other half
2398/// of what this fixes. `ureq`'s `LimitReader` errors on the next `read` once
2399/// its budget reaches zero, and `read_to_end` always makes that read to find
2400/// the end, so the cap it enforced was "at least the limit" while the error
2401/// said "larger than" it. The wire backstop has to leave room for that same
2402/// body compressed, which is not the same number — see [`wire_budget`].
2403struct CapReader<R> {
2404 reader: R,
2405 /// The cap, kept for the error; `left` is what is spent against it.
2406 limit: u64,
2407 left: u64,
2408}
2409
2410impl<R> CapReader<R> {
2411 fn new(reader: R, limit: u64) -> Self {
2412 CapReader {
2413 reader,
2414 limit,
2415 left: limit,
2416 }
2417 }
2418}
2419
2420impl<R: std::io::Read> std::io::Read for CapReader<R> {
2421 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2422 // One byte more than may be kept: enough to tell "ended exactly at the
2423 // cap" from "ran past it" without a probe read of its own, and the
2424 // byte itself is never returned to the caller.
2425 let room = self.left.saturating_add(1).min(buf.len() as u64) as usize;
2426 let read = self.reader.read(&mut buf[..room])?;
2427
2428 if read as u64 > self.left {
2429 return Err(ureq::Error::BodyExceedsLimit(self.limit).into_io());
2430 }
2431
2432 self.left -= read as u64;
2433 Ok(read)
2434 }
2435}
2436
2437/// How many bytes `ureq` may transfer for a memory cap of `limit`.
2438///
2439/// The backstop, and it is not optional: [`CapReader`] counts what comes *out*
2440/// of the decoder, so a stream that decodes to nothing never spends against it.
2441/// An endless chunked body of empty deflate stored blocks — `00 00 00 ff ff`,
2442/// repeated — makes `flate2` loop inside a single `read` producing no output,
2443/// so `CapReader::read` is never re-entered and `left` never moves. With the
2444/// wire limit removed, that read does not come back;
2445/// `an_endless_body_that_decodes_to_nothing_is_still_bounded` is it with a
2446/// deadline on it. `ureq`'s own limit sits underneath the decoder and counts
2447/// transferred bytes, which is exactly the quantity such a stream does spend.
2448///
2449/// It cannot be `limit`, or even `limit + 1`. Deflate **expands** what it
2450/// cannot compress: a body of exactly `limit` decoded bytes is the largest this
2451/// client is documented to hand back, and gzipped it crosses the wire *larger*
2452/// than that. Measured with `flate2` at 4 096 incompressible bytes: **4 119**
2453/// on the wire, which a budget of 4 097 refuses — a response inside the
2454/// documented ceiling turned away by a guard that exists to catch responses
2455/// outside it.
2456///
2457/// So the budget is zlib's own `deflateBound` — `n + n/8 + n/64 + 5`, its bound
2458/// for a deflate stream that compresses nothing — with 64 bytes covering the
2459/// gzip wrapper's header and trailer (18) and the two bytes of rounding this
2460/// gives up by shifting rather than dividing. At [`RESPONSE_LIMIT`] that is
2461/// 612 368 448 wire bytes for 536 870 912 held, so the backstop still bounds
2462/// the pathological stream at about 584 MiB of transfer.
2463///
2464/// It bounds a *conformant* encoder. One that expands past `deflateBound` — a
2465/// dynamic Huffman block per byte, which nothing writes by accident — is
2466/// refused, and refusing is the safe direction to be wrong in.
2467fn wire_budget(limit: u64) -> u64 {
2468 limit
2469 .saturating_add(limit >> 3)
2470 .saturating_add(limit >> 6)
2471 .saturating_add(64)
2472}
2473
2474/// Reads a buffered response body, capped at [`RESPONSE_LIMIT`]'s worth of
2475/// *decoded* bytes.
2476///
2477/// Two guards, counting two different things. [`CapReader`] is the cap the
2478/// caller is promised, and it sits above the decoder; the limit `ureq` is given
2479/// is the backstop underneath it, and [`wire_budget`] is why the second is not
2480/// simply the first.
2481fn read_capped(command: &str, body: &mut ureq::Body, limit: u64) -> Result<Vec<u8>> {
2482 use std::io::Read;
2483
2484 let transferred = wire_budget(limit);
2485 let mut reader = CapReader::new(body.with_config().limit(transferred).reader(), limit);
2486
2487 let mut bytes = Vec::new();
2488 reader
2489 .read_to_end(&mut bytes)
2490 .map_err(|e| body_failure(command, limit, e.into()))?;
2491
2492 Ok(bytes)
2493}
2494
2495/// Which error a buffered response body failed with, and whose fault it is.
2496///
2497/// Two failures arrive down the same road and mean opposite things.
2498///
2499/// A connection cut while the body streams in is the same network failure as
2500/// one cut a packet earlier, so it stays a [`ClientError::Transport`]: worth
2501/// waiting and repeating where the command allows it, and — for a heavy
2502/// command — a fair reason to drop the host it went to.
2503///
2504/// A body that ran past the cap is neither, and left as a `Transport` it would
2505/// be read as both. `ureq` reports it as `Error::BodyExceedsLimit`, which is
2506/// not an `Io` error, so every predicate that narrows `Transport` by looking
2507/// inside — [`crate::retry::is_retriable`], and through it
2508/// [`crate::retry::worth_asking_again`] and
2509/// [`crate::retry::attributable_to_the_host`] — answers `true` for it. The
2510/// consequences are not this caller's alone: an over-cap
2511/// [`Client::read_file`](crate::Client::read_file) would fail and take a
2512/// **healthy** data proxy out of the pool for it; enough of them empty the
2513/// pool, and the fallback window then answers unrelated *writes* with the
2514/// control-proxy refusal [#30] exists to prevent. Nothing about that is the
2515/// host's doing — it served the request perfectly — and no amount of waiting
2516/// shrinks the file.
2517///
2518/// So it becomes a [`ClientError::ResponseTooLarge`]: settled, and about the
2519/// request rather than the addressee. Never retried, never blamed on a host.
2520///
2521/// `limit` rather than the number `ureq` carries in `BodyExceedsLimit`,
2522/// because the two are not the same: what `ureq` is asked to enforce is a
2523/// transferred-byte backstop above the memory cap (see [`wire_budget`]), and
2524/// the cap the caller needs told is this one.
2525///
2526/// [#30]: https://github.com/sshaplygin/ytsaurus-rs/issues/30
2527fn body_failure(command: &str, limit: u64, error: ureq::Error) -> ClientError {
2528 if matches!(error, ureq::Error::BodyExceedsLimit(_)) {
2529 return ClientError::ResponseTooLarge {
2530 command: command.to_owned(),
2531 limit,
2532 };
2533 }
2534
2535 ClientError::Transport {
2536 command: command.to_owned(),
2537 source: Box::new(error),
2538 }
2539}
2540
2541/// Names the proxy a routed command actually went to.
2542///
2543/// `write_table: transport error: io: Connection refused` is a true report
2544/// about an address that appears nowhere in the caller's own code: the client
2545/// chose it, from a list the cluster gave it, and then said nothing about the
2546/// choice. The same misdirection as an error that blames a token for a host it
2547/// was never sent to.
2548///
2549/// Only for a command that was routed — a failure at the configured address
2550/// needs no explaining, because that is the address the caller typed.
2551fn routed_to(error: ClientError, base: &str) -> ClientError {
2552 let at = format!(" at {}", authority_of(base));
2553
2554 match error {
2555 ClientError::Transport { command, source } => ClientError::Transport {
2556 command: command + &at,
2557 source,
2558 },
2559 ClientError::Cluster {
2560 command,
2561 code,
2562 message,
2563 raw,
2564 } => ClientError::Cluster {
2565 command: command + &at,
2566 code,
2567 message,
2568 raw,
2569 },
2570 ClientError::Http {
2571 command,
2572 status,
2573 body,
2574 } => ClientError::Http {
2575 command: command + &at,
2576 status,
2577 body,
2578 },
2579 ClientError::Decode { command, reason } => ClientError::Decode {
2580 command: command + &at,
2581 reason,
2582 },
2583 // `ResponseTooLarge` carries a command too, and is deliberately *not*
2584 // qualified — which is why it is written out here rather than left to
2585 // fall through. Its message offers the streaming half of the same
2586 // command, and `error::streaming_advice` finds that half by matching
2587 // the command name **exactly**: `read_file at n0132-sas.example.net`
2588 // matches nothing, and the sentence saying what to do instead
2589 // disappears. The host is not worth naming in any case — this failure
2590 // is about the size of the answer, and the answer is exactly as large
2591 // at the next proxy along. `a_response_too_large_keeps_the_way_past_it`
2592 // fails if this arm is deleted.
2593 error @ ClientError::ResponseTooLarge { .. } => error,
2594 // Nothing else carries a command at all: an `Io` names a local path, a
2595 // `Config` names the build, and an `OperationFailed` is the
2596 // scheduler's verdict rather than one proxy's.
2597 other => other,
2598 }
2599}
2600
2601/// Whether `base` names a cluster on this machine, or a tunnel to one.
2602///
2603/// Such a cluster is not asked where its heavy proxies are, and this is the
2604/// one place that decision is made. Two reasons, and either would do:
2605///
2606/// - a single-node installation has no separate heavy proxies, so the lookup
2607/// can only cost a round trip before the first upload;
2608/// - the address a cluster publishes for itself is its own, and from behind a
2609/// port mapping or an SSH tunnel it is not reachable at all. A local
2610/// YTsaurus in Docker is reached at `localhost:8000` and knows itself by the
2611/// container's address and port — following that would send every upload
2612/// somewhere this process cannot go.
2613///
2614/// So the default is "ask, unless the address says it cannot help", and
2615/// `Client::with_proxy_discovery` overrides it in both directions.
2616fn is_local(base: &str) -> bool {
2617 let host = host_of(base);
2618
2619 if let Ok(address) = host.parse::<std::net::IpAddr>() {
2620 // `is_unspecified` covers `0.0.0.0`, which is not loopback but is
2621 // nobody else's address either.
2622 return address.is_loopback() || address.is_unspecified();
2623 }
2624
2625 host.eq_ignore_ascii_case("localhost")
2626}
2627
2628/// The host out of a base URL, without scheme, port or path.
2629///
2630/// `http://[::1]:8000` is why this is not a `split(':')`: an IPv6 literal is
2631/// bracketed and full of colons.
2632fn host_of(base: &str) -> &str {
2633 let authority = base
2634 .split_once("://")
2635 .map_or(base, |(_, rest)| rest)
2636 .split(['/', '?', '#'])
2637 .next()
2638 .unwrap_or_default();
2639 let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
2640
2641 match authority.strip_prefix('[') {
2642 Some(literal) => literal.split(']').next().unwrap_or_default(),
2643 None => authority.split(':').next().unwrap_or_default(),
2644 }
2645}
2646
2647/// What is left of `deadline` for the next request.
2648///
2649/// `Ok(None)` when there is no deadline to share out, and `Err` when the
2650/// command has already spent it — reported the way `ureq` reports the same
2651/// exhaustion from inside a request, so that a caller sees one answer whether
2652/// the budget ran out mid-request or between two hops of a redirect chain.
2653fn remaining(deadline: Option<Instant>, command: &str) -> Result<Option<Duration>> {
2654 let Some(deadline) = deadline else {
2655 return Ok(None);
2656 };
2657
2658 match deadline.checked_duration_since(Instant::now()) {
2659 Some(left) if !left.is_zero() => Ok(Some(left)),
2660 _ => Err(ClientError::Transport {
2661 command: command.to_owned(),
2662 source: Box::new(ureq::Error::Timeout(ureq::Timeout::Global)),
2663 }),
2664 }
2665}
2666
2667/// The one place the agent is configured, so a timeout change rebuilds it the
2668/// same way it was first built.
2669///
2670/// **`ureq` follows nothing.** Not because this client refuses redirects — it
2671/// follows plenty — but because the answer depends on three things at once:
2672/// the credentials the request carries, whether the redirect leaves the origin
2673/// the request was addressed to, and whether there is a body a redirect would
2674/// drop. No combination of `max_redirects` and `redirect_auth_headers`
2675/// expresses that, so the following is done in [`Transport::redirect`], where
2676/// all three are in hand. `max_redirects(0)` does not mean "fail on a
2677/// redirect": it hands the `3xx` back as an ordinary response, which is what
2678/// gives that decision something to read.
2679///
2680/// A note for whoever arrives here meaning to reach for
2681/// `redirect_auth_headers(RedirectAuthHeaders::SameHost)`: **it does not
2682/// help.** The redirect this exists for is a control proxy pointing at a data
2683/// proxy on **another** host, which is precisely the case `SameHost` does not
2684/// cover — it would drop the header and go anyway.
2685///
2686/// `named` is the bundle to trust — [`configured_bundle`] in production, and
2687/// whatever a test wants to hand it. A parameter rather than a second reading
2688/// of the environment, so the whole chain from a named file to an agent that
2689/// carries its roots can be exercised without writing a process-global
2690/// variable.
2691///
2692/// Hands back whatever it could not honour instead of failing: this runs while
2693/// a client is being constructed, where there is no request to fail and no
2694/// `Result` to fail into. See [`Transport::unusable`], which is where the
2695/// refusal is finally spoken.
2696///
2697/// A build without TLS has no handshake for a bundle to configure, so it takes
2698/// `named` and ignores it — one signature is better than two of them behind a
2699/// `cfg`.
2700#[cfg_attr(not(feature = "tls"), allow(unused_variables))]
2701fn build_agent(timeout: Duration, named: Option<&Path>) -> (ureq::Agent, Option<String>) {
2702 #[allow(unused_mut)]
2703 let mut builder = ureq::Agent::config_builder()
2704 .timeout_global(Some(timeout))
2705 // Keep non-2xx as ordinary responses so the X-YT-Error header can be
2706 // read off them; ureq would otherwise collapse them to a status code
2707 // and discard the cluster's explanation.
2708 .http_status_as_error(false)
2709 // From #36: this client follows redirects itself, in `Transport::redirect`,
2710 // so `ureq` must hand the 3xx back rather than chase it. Not "fail on a
2711 // redirect" — the 3xx becomes an ordinary response for that code to read.
2712 .max_redirects(0);
2713
2714 #[allow(unused_mut)]
2715 let mut refused = None;
2716
2717 #[cfg(feature = "tls")]
2718 match root_certs(named) {
2719 Ok(Some(tls)) => builder = builder.tls_config(tls),
2720 Ok(None) => {}
2721 Err(why) => refused = Some(why),
2722 }
2723
2724 (builder.build().into(), refused)
2725}
2726
2727/// The bundle this process was pointed at, read from the environment once.
2728///
2729/// [`std::env::var_os`] rather than `var`: a path is not text, and a
2730/// `YT_CA_BUNDLE` that is not UTF-8 would be swallowed as "unset" by the
2731/// stricter one — the same silent fall-through the variable exists to end.
2732///
2733/// A build without TLS names nothing, which is the honest answer: there is no
2734/// handshake to configure, so the variable is read no more than a socket is
2735/// opened for `https://`.
2736#[cfg(feature = "tls")]
2737fn configured_bundle() -> Option<&'static Path> {
2738 static NAMED: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
2739
2740 NAMED
2741 .get_or_init(|| std::env::var_os(CA_BUNDLE).map(std::path::PathBuf::from))
2742 .as_deref()
2743}
2744
2745#[cfg(not(feature = "tls"))]
2746fn configured_bundle() -> Option<&'static Path> {
2747 None
2748}
2749
2750/// Which roots the cluster's certificate is verified against.
2751///
2752/// `None` leaves `ureq`'s own default, the Mozilla bundle compiled in through
2753/// `webpki-roots`. That is what a cluster with a publicly trusted certificate
2754/// wants, and it stays the default here: a client may well run outside the
2755/// network it is talking to, where the machine's own trust store is the less
2756/// trustworthy of the two.
2757///
2758/// An on-premises installation behind a corporate CA is the case that needs
2759/// changing, and there are two ways to do it — the same two the `yt` CLI and
2760/// the Go SDK offer:
2761///
2762/// - **[`CA_BUNDLE`]** names a PEM file. No dependency at all, and nothing to
2763/// rebuild.
2764/// - the **`platform-verifier`** feature trusts whatever the operating system
2765/// trusts, so a machine where `curl` already reaches the cluster needs
2766/// nothing set.
2767///
2768/// The bundle wins when both are there. It is the more specific answer, and
2769/// the one the caller went out of their way to give.
2770///
2771/// **The configured bundle is read and parsed once per process.** An agent is
2772/// rebuilt more often than it looks: [`Transport::set_timeout`] makes a new
2773/// one, and `Transaction::start` and its `Drop` each build a client, so an
2774/// uncached read cost three parses per transaction of a file whose answer
2775/// cannot have changed meaning in between. Anything *other* than the
2776/// configured bundle is parsed on the spot — only a test ever asks for one,
2777/// and a memo keyed on nothing would hand it the first test's answer.
2778///
2779/// **Only success is remembered.** Memoising the failure too would pin a
2780/// passing condition for the life of the process: a first `Client::new` that
2781/// lands while config management is rewriting the file in place, or before the
2782/// mount carrying it is ready, would leave every later client in that process
2783/// refusing to send anything — with no way back short of a restart. That is
2784/// the same "make a bad afternoon permanent" mistake this module argues
2785/// against in [`crate::retry`]'s certificate classification, and it would be
2786/// odd to commit it here. A failed read is simply tried again next time; the
2787/// cost is bounded by the size cap, and a bundle that is genuinely broken pays
2788/// it only on the construction path it was already failing.
2789#[cfg(feature = "tls")]
2790fn root_certs(named: Option<&Path>) -> Result<Option<ureq::tls::TlsConfig>, String> {
2791 static CONFIGURED: std::sync::OnceLock<Option<ureq::tls::TlsConfig>> =
2792 std::sync::OnceLock::new();
2793
2794 if named == configured_bundle() {
2795 if let Some(roots) = CONFIGURED.get() {
2796 return Ok(roots.clone());
2797 }
2798
2799 let roots = roots_for(named)?;
2800 // A race here is harmless: two threads that both parsed the same file
2801 // agree about it, and the loser drops its copy.
2802 let _ = CONFIGURED.set(roots.clone());
2803 return Ok(roots);
2804 }
2805
2806 roots_for(named)
2807}
2808
2809/// The choice itself, split from the lookup so it can be tested without writing
2810/// the process environment — which is global, and in edition 2024 unsafe to
2811/// write.
2812#[cfg(feature = "tls")]
2813fn roots_for(named: Option<&Path>) -> Result<Option<ureq::tls::TlsConfig>, String> {
2814 match named {
2815 // An empty variable is not a bundle: `YT_CA_BUNDLE=` in a shell profile
2816 // means "I turned that off", not "trust a file called nothing".
2817 Some(path) if !names_nothing(path) => bundle(path).map(Some),
2818 _ => Ok(platform_roots()),
2819 }
2820}
2821
2822/// Whether a variable that is set nevertheless names no file.
2823///
2824/// `YT_CA_BUNDLE=` and `YT_CA_BUNDLE=" "` are both how a shell profile turns
2825/// one off; read as paths they would be a refusal on every request. A path that
2826/// is not UTF-8 is *not* nothing — it is a path this crate cannot spell, which
2827/// is exactly the case [`configured_bundle`] reads as `OsString` to keep.
2828#[cfg(feature = "tls")]
2829fn names_nothing(path: &Path) -> bool {
2830 path.to_str().is_some_and(|text| text.trim().is_empty())
2831}
2832
2833/// What to trust when nothing named a bundle.
2834///
2835/// `None` is `ureq`'s own default and this crate's: the Mozilla roots.
2836#[cfg(feature = "tls")]
2837fn platform_roots() -> Option<ureq::tls::TlsConfig> {
2838 #[cfg(feature = "platform-verifier")]
2839 {
2840 return Some(
2841 ureq::tls::TlsConfig::builder()
2842 .root_certs(ureq::tls::RootCerts::PlatformVerifier)
2843 .build(),
2844 );
2845 }
2846
2847 #[allow(unreachable_code)]
2848 None
2849}
2850
2851/// Reads a PEM file into the roots to trust, or says why it could not.
2852///
2853/// Split from [`roots_for`] so the reading and the refusal can be tested
2854/// against a file of their own.
2855///
2856/// **A bundle that yields no certificates is refused, not ignored.** Falling
2857/// back to the compiled-in roots would answer a deliberate request with the
2858/// very handshake failure this variable exists to end — and it would do it
2859/// silently, naming neither the file nor the reason. The same goes for a file
2860/// that cannot be read: `YT_CA_BUNDLE` pointing at a typo is a mistake worth
2861/// hearing about at the first request rather than at the first `UnknownIssuer`.
2862///
2863/// **And for a block that is labelled a certificate and is not one.** PEM is an
2864/// envelope: `parse_pem` splits the sections and base64-decodes them, and
2865/// checks nothing about what comes out — `Certificate::from_der`'s own
2866/// documentation says the validation "is the responsibility of the TLS
2867/// provider". That provider is `rustls`, whose `add_parsable_certificates`
2868/// *drops* what it cannot parse and reports the count to nobody. So a `.p7b`
2869/// re-armoured under a `BEGIN CERTIFICATE` label — the usual way a Windows-born
2870/// bundle arrives — was accepted here, produced an empty root store, and failed
2871/// every request with the same `UnknownIssuer` that named neither the file nor
2872/// the variable. [`is_x509`] is the check that closes it, and **one bad block
2873/// refuses the whole file** rather than trusting a silently shorter set of
2874/// roots than the caller wrote down.
2875#[cfg(feature = "tls")]
2876fn bundle(path: &Path) -> Result<ureq::tls::TlsConfig, String> {
2877 use std::io::Read;
2878
2879 use ureq::tls::{Certificate, PemItem, RootCerts, TlsConfig, parse_pem};
2880
2881 let shown = path.display();
2882
2883 // `stat` before `open`, and not only for the size: opening a FIFO for
2884 // reading blocks until someone writes to it, and there is nothing above
2885 // this to time it out — `Client::new` is infallible and the client's
2886 // global timeout covers requests, not files. A named pipe left in a
2887 // variable would hang the constructor for ever.
2888 let found = std::fs::metadata(path)
2889 .map_err(|e| format!("{CA_BUNDLE} names {shown}, which could not be read: {e}"))?;
2890
2891 if !found.is_file() {
2892 return Err(format!(
2893 "{CA_BUNDLE} names {shown}, which is not a regular file: a root bundle is read whole, \
2894 and a directory or a pipe has no end to read to"
2895 ));
2896 }
2897
2898 if found.len() > MAX_BUNDLE_BYTES {
2899 return Err(format!(
2900 "{CA_BUNDLE} names {shown}, which is {} bytes: a root bundle is a few hundred \
2901 kilobytes and this reader stops at {MAX_BUNDLE_BYTES}",
2902 found.len()
2903 ));
2904 }
2905
2906 let mut pem = Vec::new();
2907 std::fs::File::open(path)
2908 // The cap again on the read itself, since a file can grow between the
2909 // two calls. One byte over is enough to notice.
2910 .and_then(|file| file.take(MAX_BUNDLE_BYTES + 1).read_to_end(&mut pem))
2911 .map_err(|e| format!("{CA_BUNDLE} names {shown}, which could not be read: {e}"))?;
2912
2913 if pem.len() as u64 > MAX_BUNDLE_BYTES {
2914 return Err(format!(
2915 "{CA_BUNDLE} names {shown}, which grew past {MAX_BUNDLE_BYTES} bytes while it was \
2916 being read"
2917 ));
2918 }
2919
2920 let mut certs: Vec<Certificate<'static>> = Vec::new();
2921 let mut unparsable = 0usize;
2922 let mut damaged: Option<String> = None;
2923
2924 for item in parse_pem(&pem) {
2925 match item {
2926 Ok(PemItem::Certificate(cert)) if is_x509(cert.der()) => certs.push(cert),
2927 Ok(PemItem::Certificate(_)) => unparsable += 1,
2928 // A private key, or a section this `ureq` does not recognise. Not a
2929 // root, and not a mistake either: a deployment that keeps its key
2930 // and its CA in one file is ordinary.
2931 Ok(_) => {}
2932 // A section that did not survive the envelope: corrupt base64, or a
2933 // file that stops mid-block. Counted rather than skipped, because
2934 // skipping it is the silent truncation this whole function exists
2935 // to end — the roots would simply be fewer than the file says, and
2936 // the first request would fail `UnknownIssuer` naming neither.
2937 // Ordinary bundles do not reach here: leading comments and labels
2938 // between blocks parse cleanly, so this is damage, not decoration.
2939 Err(why) => {
2940 damaged.get_or_insert_with(|| why.to_string());
2941 }
2942 }
2943 }
2944
2945 if let Some(why) = damaged {
2946 return Err(format!(
2947 "{CA_BUNDLE} names {shown}, which holds a section that could not be read: {why}. A \
2948 truncated download or a mangled copy-paste is the usual cause; the roots that did \
2949 parse are deliberately not used, because a bundle that is quietly shorter than the \
2950 file names is worse than one that is refused"
2951 ));
2952 }
2953
2954 if unparsable > 0 {
2955 return Err(format!(
2956 "{CA_BUNDLE} names {shown}, where {unparsable} of {} -----BEGIN CERTIFICATE----- \
2957 blocks hold something that is not an X.509 certificate. A PKCS#7 `.p7b` re-armoured \
2958 under that label is the usual cause; `openssl pkcs7 -print_certs` converts one",
2959 certs.len() + unparsable
2960 ));
2961 }
2962
2963 if certs.is_empty() {
2964 return Err(format!(
2965 "{CA_BUNDLE} names {shown}, which holds no PEM certificates: expected at least one \
2966 -----BEGIN CERTIFICATE----- block"
2967 ));
2968 }
2969
2970 Ok(TlsConfig::builder()
2971 .root_certs(RootCerts::new_with_certs(&certs))
2972 .build())
2973}
2974
2975/// DER tags, as far as a certificate's skeleton uses them.
2976#[cfg(feature = "tls")]
2977mod der {
2978 pub(super) const INTEGER: u8 = 0x02;
2979 pub(super) const BIT_STRING: u8 = 0x03;
2980 pub(super) const SEQUENCE: u8 = 0x30;
2981 /// `[0] EXPLICIT`, which is where a certificate's version lives — and where
2982 /// it is absent on a v1 one.
2983 pub(super) const VERSION: u8 = 0xa0;
2984}
2985
2986/// Whether these bytes really are an X.509 certificate.
2987///
2988/// Not a verification and not a full parse: the question is only whether
2989/// `rustls` will find a certificate here, because what it does with something
2990/// else is discard it in silence. Checking the shape is what turns that into a
2991/// sentence naming the file. See [`bundle`].
2992///
2993/// ```text
2994/// Certificate ::= SEQUENCE {
2995/// tbsCertificate TBSCertificate,
2996/// signatureAlgorithm AlgorithmIdentifier,
2997/// signatureValue BIT STRING }
2998/// ```
2999///
3000/// A PKCS#7 `ContentInfo` — the `.p7b` this exists for — is also a `SEQUENCE`,
3001/// but its first member is an OBJECT IDENTIFIER rather than the
3002/// `tbsCertificate` sequence, so it parts company on the second field and needs
3003/// nothing deeper to tell apart. The `tbsCertificate` check goes deeper anyway:
3004/// a shape that agrees this far and disagrees inside is not something anyone
3005/// would call a certificate.
3006#[cfg(feature = "tls")]
3007fn is_x509(der: &[u8]) -> bool {
3008 let Some((body, after)) = expect(der, der::SEQUENCE) else {
3009 return false;
3010 };
3011 if !after.is_empty() {
3012 return false;
3013 }
3014
3015 let Some((tbs, rest)) = expect(body, der::SEQUENCE) else {
3016 return false;
3017 };
3018 let Some((_, rest)) = expect(rest, der::SEQUENCE) else {
3019 return false;
3020 };
3021 let Some((_, rest)) = expect(rest, der::BIT_STRING) else {
3022 return false;
3023 };
3024
3025 rest.is_empty() && is_tbs_certificate(tbs)
3026}
3027
3028/// The fixed head of a `TBSCertificate`: an optional version, a serial number,
3029/// and five `SEQUENCE`s — signature, issuer, validity, subject and the public
3030/// key. What may follow those is optional and version-dependent, and proves
3031/// nothing more than they already have.
3032#[cfg(feature = "tls")]
3033fn is_tbs_certificate(tbs: &[u8]) -> bool {
3034 let after_version = match tlv(tbs) {
3035 Some((tag, _, rest)) if tag == der::VERSION => rest,
3036 // Absent on a v1 certificate, where the serial number comes first.
3037 _ => tbs,
3038 };
3039
3040 let Some((_, mut rest)) = expect(after_version, der::INTEGER) else {
3041 return false;
3042 };
3043
3044 for _ in 0..5 {
3045 let Some((_, next)) = expect(rest, der::SEQUENCE) else {
3046 return false;
3047 };
3048 rest = next;
3049 }
3050
3051 true
3052}
3053
3054/// One DER value of the tag asked for: its contents, and what follows it.
3055#[cfg(feature = "tls")]
3056fn expect(input: &[u8], tag: u8) -> Option<(&[u8], &[u8])> {
3057 match tlv(input) {
3058 Some((found, contents, rest)) if found == tag => Some((contents, rest)),
3059 _ => None,
3060 }
3061}
3062
3063/// Splits one DER tag-length-value off the front of `input`.
3064///
3065/// Only what a certificate's skeleton uses: single-byte tags and definite,
3066/// minimally encoded lengths. The indefinite form is BER and not DER, a
3067/// non-minimal length is not DER either, and neither belongs in a file anyone
3068/// should be trusting a cluster's identity to.
3069#[cfg(feature = "tls")]
3070fn tlv(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
3071 let (&tag, rest) = input.split_first()?;
3072
3073 // The high-tag-number form, which nothing in a certificate's skeleton uses.
3074 if tag & 0x1f == 0x1f {
3075 return None;
3076 }
3077
3078 let (&first, rest) = rest.split_first()?;
3079 let (length, rest) = if first < 0x80 {
3080 (usize::from(first), rest)
3081 } else {
3082 let count = usize::from(first & 0x7f);
3083 // 0x80 is the indefinite form. Four bytes is 4 GB, which is more than
3084 // any bundle this reads and more than `MAX_BUNDLE_BYTES` allows.
3085 if count == 0 || count > 4 {
3086 return None;
3087 }
3088 let (bytes, rest) = rest.split_at_checked(count)?;
3089 // A leading zero, or a value the short form would have held, is a
3090 // length DER does not spell that way.
3091 if bytes[0] == 0 || (count == 1 && bytes[0] < 0x80) {
3092 return None;
3093 }
3094 let length = bytes
3095 .iter()
3096 .fold(0usize, |whole, byte| (whole << 8) | usize::from(*byte));
3097 (length, rest)
3098 };
3099
3100 let (contents, rest) = rest.split_at_checked(length)?;
3101 Some((tag, contents, rest))
3102}
3103
3104/// Refuses an `https://` proxy when the crate was built without TLS.
3105///
3106/// Without this the failure surfaces as a connection error from `ureq`, which
3107/// says nothing about the missing feature. See the `tls` feature: it is off in
3108/// worker builds so that a binary which both launches and runs jobs can be
3109/// cross-compiled to musl without a C toolchain.
3110#[cfg(not(feature = "tls"))]
3111fn tls_unavailable(base: &str) -> Option<ClientError> {
3112 base.starts_with("https://").then(|| {
3113 ClientError::Config(format!(
3114 "{base} needs TLS, and this build has none: the `tls` feature of \
3115 ytsaurus-client is off. Enable it, or use an http:// proxy."
3116 ))
3117 })
3118}
3119
3120#[cfg(feature = "tls")]
3121fn tls_unavailable(_base: &str) -> Option<ClientError> {
3122 None
3123}
3124
3125/// The HTTP verb a command is sent with.
3126///
3127/// Which one a command wants is not a matter of taste. The
3128/// [HTTP proxy reference](https://ytsaurus.tech/docs/en/user-guide/proxy/http-reference)
3129/// gives the rule outright:
3130///
3131/// > If the command has an input data stream, then PUT. If the command is
3132/// > mutating, then POST. Otherwise GET.
3133///
3134/// Those three properties are declared per command in the cluster's own driver
3135/// registry, so the answer for a command this crate does not model is a lookup
3136/// rather than a guess: `write_table` takes a data stream and is a PUT, `create`
3137/// mutates and is a POST, `get` and `get_supported_features` do neither and are
3138/// GETs.
3139///
3140/// Public because [`Client::raw_command`](crate::Client::raw_command) cannot
3141/// choose for a command it has never heard of.
3142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3143pub enum Method {
3144 /// A command that neither mutates nor takes an input stream.
3145 Get,
3146 /// A mutating command with no input stream — most of API v4.
3147 Post,
3148 /// A command with an input data stream: `write_table`, `write_file`.
3149 Put,
3150}
3151
3152fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
3153 headers
3154 .get(name)
3155 .and_then(|value| value.to_str().ok())
3156 .map(str::to_owned)
3157}
3158
3159/// Resolves a `Location` against the address the request went to.
3160///
3161/// `Location` was required to be absolute until RFC 7231 relaxed it, and
3162/// balancers took the permission: `Location: /api/v4/exists?path=…` is an
3163/// ordinary answer. Reporting that back as "redirected to /api/v4/exists"
3164/// names no host, and comparing it against one decides nothing — so it is made
3165/// absolute first, and everything downstream sees an address.
3166///
3167/// The four forms of [RFC 3986 §4.2](https://www.rfc-editor.org/rfc/rfc3986#section-4.2),
3168/// in the order they are tried: an absolute URI keeps its own scheme and
3169/// authority; a network-path reference (`//host/path`) keeps the scheme; an
3170/// absolute-path reference (`/path`) keeps scheme and authority; a relative
3171/// reference keeps everything down to the directory the request's path is in.
3172///
3173/// The last of those has two forms with **no path of their own**, and
3174/// [§5.3](https://www.rfc-editor.org/rfc/rfc3986#section-5.3) is explicit that
3175/// they keep the base's: `Location: ?path=//other` against
3176/// `/api/v4/exists?path=//tmp` is `/api/v4/exists?path=//other`, not
3177/// `/api/v4/?path=//other`, and `Location: #frag` keeps the query as well.
3178/// Getting that wrong costs a `404` rather than a credential — the origin is
3179/// the same either way — but it is a `404` for a request the proxy meant to
3180/// answer.
3181///
3182/// `None` for a `Location` this cannot place — an empty one, or a request
3183/// address with no `scheme://`. The caller treats that as "not a redirect this
3184/// client acts on" rather than inventing a host for it.
3185fn resolve(request: &str, location: &str) -> Option<String> {
3186 let location = location.trim();
3187 if location.is_empty() {
3188 return None;
3189 }
3190 if has_scheme(location) {
3191 return Some(location.to_owned());
3192 }
3193
3194 let (scheme, rest) = request.split_once("://")?;
3195 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
3196 let (authority, target) = rest.split_at(end);
3197 if authority.is_empty() {
3198 return None;
3199 }
3200
3201 if let Some(elsewhere) = location.strip_prefix("//") {
3202 return Some(format!("{scheme}://{elsewhere}"));
3203 }
3204 if location.starts_with('/') {
3205 return Some(format!("{scheme}://{authority}{location}"));
3206 }
3207
3208 // The base's path and query, without the fragment: a fragment is never
3209 // part of what a reference is resolved against.
3210 let base = target.split('#').next().unwrap_or("");
3211 let path = base.split('?').next().unwrap_or("");
3212
3213 // A reference with no path of its own keeps the base's — and a bare
3214 // fragment keeps the base's query too, where a query of its own replaces
3215 // it.
3216 if location.starts_with('#') {
3217 return Some(format!("{scheme}://{authority}{base}{location}"));
3218 }
3219 if location.starts_with('?') {
3220 return Some(format!("{scheme}://{authority}{path}{location}"));
3221 }
3222
3223 // A relative path is merged with the directory the base's path is in, and
3224 // takes the query with it: that one belonged to the old path.
3225 let directory = path.rsplit_once('/').map_or("", |(head, _)| head);
3226 Some(format!("{scheme}://{authority}{directory}/{location}"))
3227}
3228
3229/// Whether a string begins with a URI scheme — `ALPHA *( ALPHA / DIGIT / "+" /
3230/// "-" / "." ) ":"`, and the colon must come before any path, query or
3231/// fragment. `//host/x` and `/x:y` are not absolute; `HTTPS://h` is.
3232fn has_scheme(url: &str) -> bool {
3233 let Some(colon) = url.find(':') else {
3234 return false;
3235 };
3236 let scheme = &url[..colon];
3237 !scheme.is_empty()
3238 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
3239 && scheme
3240 .chars()
3241 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
3242}
3243
3244/// Whether two absolute URLs share an origin: scheme, host and port.
3245///
3246/// The comparison a credential-carrying redirect turns on, so it is made to be
3247/// unfooled rather than to be brief. Userinfo is not part of an origin, and
3248/// dropping it is what stops `http://real.example.net@evil.example.net/` from
3249/// reading as `real.example.net`. A missing port is the scheme's default, so
3250/// `https://h` and `https://h:443` are one origin and `http://h` is not.
3251///
3252/// Fails closed: a URL either side cannot be split into an origin is not the
3253/// same origin as anything, including itself.
3254fn same_origin(one: &str, other: &str) -> bool {
3255 match (origin(one), origin(other)) {
3256 (Some(one), Some(other)) => one == other,
3257 _ => false,
3258 }
3259}
3260
3261fn origin(url: &str) -> Option<(String, String, u16)> {
3262 let (scheme, rest) = url.split_once("://")?;
3263 let scheme = scheme.to_ascii_lowercase();
3264 let port = match scheme.as_str() {
3265 "http" => 80,
3266 "https" => 443,
3267 // An origin needs a port, and a scheme this client does not speak has
3268 // no default to supply one.
3269 _ => return None,
3270 };
3271
3272 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
3273 let authority = &rest[..end];
3274 let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
3275
3276 // `[::1]:8080` splits at the last colon; `[::1]` has colons and no port.
3277 let (host, port) = match host_port.rsplit_once(':') {
3278 Some((host, given)) if !given.is_empty() && given.bytes().all(|b| b.is_ascii_digit()) => {
3279 (host, given.parse().ok()?)
3280 }
3281 _ => (host_port, port),
3282 };
3283 if host.is_empty() {
3284 return None;
3285 }
3286
3287 Some((scheme, host.to_ascii_lowercase(), port))
3288}
3289
3290#[cfg(test)]
3291mod tests {
3292 use super::*;
3293 use crate::yson_build::map;
3294
3295 fn transport(transaction: Option<&str>) -> Transport {
3296 let mut transport = Transport::new("http://localhost:8000", None, Duration::from_secs(1));
3297 transport.set_transaction(transaction.map(str::to_owned));
3298 transport
3299 }
3300
3301 fn authenticated() -> Transport {
3302 Transport::new(
3303 "http://localhost:8000",
3304 Some("secret-token".to_owned()),
3305 Duration::from_secs(1),
3306 )
3307 }
3308
3309 fn rendered(value: &YsonValue) -> String {
3310 to_string(value, YsonFormat::Text).expect("encodes")
3311 }
3312
3313 #[test]
3314 fn a_bound_client_puts_every_command_in_its_transaction() {
3315 let params = map([("path", string("//tmp/out"))]);
3316 let stamped = transport(Some("3-5d231-10001-db88"))
3317 .in_transaction("write_table", ¶ms)
3318 .expect("stamped");
3319
3320 assert_eq!(
3321 rendered(&stamped),
3322 r#"{path="//tmp/out";transaction_id="3-5d231-10001-db88"}"#
3323 );
3324 }
3325
3326 #[test]
3327 fn an_unbound_client_leaves_the_parameters_alone() {
3328 // `None` rather than a copy: this is every command's hot path.
3329 let params = map([("path", string("//tmp/out"))]);
3330 assert!(transport(None).in_transaction("get", ¶ms).is_none());
3331 }
3332
3333 #[test]
3334 fn a_command_that_names_a_transaction_keeps_the_one_it_named() {
3335 // `Transaction::commit` sends `commit_transaction` through a client
3336 // bound to that same transaction. Overwriting the parameter here would
3337 // still work — but on a *nested* transaction it would commit the child
3338 // instead of the parent the caller asked for.
3339 let params = map([("transaction_id", string("the-one-i-meant"))]);
3340 assert!(
3341 transport(Some("some-other-one"))
3342 .in_transaction("commit_transaction", ¶ms)
3343 .is_none()
3344 );
3345 }
3346
3347 #[test]
3348 fn a_scheduler_command_is_not_put_in_a_transaction() {
3349 // `Transaction` derefs to `Client`, so `tx.wait_for_operation(&id)` is
3350 // ordinary usage — and it, plus the three diagnostic calls it makes on
3351 // a failure, go to the scheduler, which has no transaction to put them
3352 // in. Stamping them survives only as long as the proxy ignores
3353 // parameters it does not know.
3354 let params = map([("operation_id", string("1-2-3-4"))]);
3355 let bound = transport(Some("3-5d231-10001-db88"));
3356
3357 for command in [
3358 "get_operation",
3359 "list_jobs",
3360 "get_job_stderr",
3361 "abort_operation",
3362 ] {
3363 assert!(
3364 bound.in_transaction(command, ¶ms).is_none(),
3365 "{command} was stamped with a transaction id"
3366 );
3367 }
3368 }
3369
3370 /// A real self-signed CA, generated for these tests with `openssl req
3371 /// -x509`. A made-up base64 blob would parse just as well — the PEM reader
3372 /// only splits sections — but then the fixture would prove nothing about
3373 /// the shape of the thing an installation would actually hand us.
3374 #[cfg(feature = "tls")]
3375 const CA_PEM: &str = "\
3376-----BEGIN CERTIFICATE-----
3377MIIDHTCCAgWgAwIBAgIUf6mwbBS7JGIyvPDkCpiBRHp914cwDQYJKoZIhvcNAQEL
3378BQAwHjEcMBoGA1UEAwwTeXRzYXVydXMtcnMgdGVzdCBDQTAeFw0yNjA4MDYyMDM4
3379MTJaFw00NjA4MDEyMDM4MTJaMB4xHDAaBgNVBAMME3l0c2F1cnVzLXJzIHRlc3Qg
3380Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDqPTrcPPGiHlv4aV8v
3381AdrNtzvlhHciQbd7Pz0tLCmn8OGCjwt3Q/V22h6HSWijIleHPqn6bTSMYfPGAxRe
3382mAiqSsMLpM+GYWZAg8Kz7VSsK4f0s4dW6i82QYFVk/+04N/0RUJ3A9RTloxSl8+a
3383HT5MF2x4LGr1eBgpz4UEsC5cJtkzA8OCM2a2TtNiuo/PtKzZx2TuvEk+Ub5Gn/lt
3384tZn8m9z6o8n51D3vEIfHfXPyFre2+cz+Ao680kc0KP8PWlG89mhvMZ2VYGJG2T/Z
33856Ddpj7aXM+jKCCjBTLMkLYaIuNO9//72kmBYsVgaBAMNYMBaBqQX1TOjwxbiBbv5
3386fbJnAgMBAAGjUzBRMB0GA1UdDgQWBBSniLAZD6er7hHpwg12hIX57PHb2TAfBgNV
3387HSMEGDAWgBSniLAZD6er7hHpwg12hIX57PHb2TAPBgNVHRMBAf8EBTADAQH/MA0G
3388CSqGSIb3DQEBCwUAA4IBAQBsR5VKflwEwRTNY1dobAWKS6kLTszpRFlQN2qBMTv+
3389NhS0i7mrNUzKadZkmlQuOMIhZl6gR4mB0XVPgkJKJ+ch8SfuaBW3Po4dTdrKfB6K
3390CgCTM54UB3QQAlAjpVhLCS7aCT8hgKEX1+1OD1SmBNQ/Jj9OOoKxVkq9prjSzILW
3391pXeT/OKKRqZ7tjG2jh55XPgE+GWLCfo3VsPqcleAoxQEWATryTF4fwKI9tuAgJ8p
3392pN1M6UxJFatwx23InC/jVPR6wBu5h1SyCjIxuW/j8pgriTm8wR3XaTly49j6VQDH
33938KGhyM+0UsZEWeI05Uq9c/Vs5TlJAcnvwJwxJqREhlHY
3394-----END CERTIFICATE-----
3395";
3396
3397 /// The key half of a pair. A file holding only this is the mistake the
3398 /// empty-parse refusal is for: it is PEM, it is a well-formed section, and
3399 /// it contains no root to trust.
3400 #[cfg(feature = "tls")]
3401 const KEY_PEM: &str = "\
3402-----BEGIN PRIVATE KEY-----
3403MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgt4eMMaSBwIKAgwrT
3404zzKo64LyF0YMvm3I61+EK3DDRDmhRANCAAS3XrEb3d5QdjQGGuAny4phX9xstUpp
3405B7b7J0xB2R7nPBn3+4PRz/35FJrHFmNkKD47D6ZMldYk7ykxNLNBGzIU
3406-----END PRIVATE KEY-----
3407";
3408
3409 /// The same self-signed CA as [`CA_PEM`], turned into a PKCS#7 `.p7b` with
3410 /// `openssl crl2pkcs7 -nocrl -certfile ca.pem -outform DER` and then
3411 /// base64-armoured under a `CERTIFICATE` label — which is what a Windows
3412 /// export converted by hand actually looks like.
3413 ///
3414 /// Genuine, not hand-waved: it decodes, it is well-formed DER, and it is a
3415 /// `ContentInfo` rather than a `Certificate`. `parse_pem` takes it, `rustls`
3416 /// drops it without a word, and the root store that comes out is empty.
3417 /// That is the whole defect, in one constant.
3418 #[cfg(feature = "tls")]
3419 const REARMOURED_P7B: &str = "\
3420-----BEGIN CERTIFICATE-----
3421MIIDTAYJKoZIhvcNAQcCoIIDPTCCAzkCAQExADALBgkqhkiG9w0BBwGgggMhMIID
3422HTCCAgWgAwIBAgIUf6mwbBS7JGIyvPDkCpiBRHp914cwDQYJKoZIhvcNAQELBQAw
3423HjEcMBoGA1UEAwwTeXRzYXVydXMtcnMgdGVzdCBDQTAeFw0yNjA4MDYyMDM4MTJa
3424Fw00NjA4MDEyMDM4MTJaMB4xHDAaBgNVBAMME3l0c2F1cnVzLXJzIHRlc3QgQ0Ew
3425ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDqPTrcPPGiHlv4aV8vAdrN
3426tzvlhHciQbd7Pz0tLCmn8OGCjwt3Q/V22h6HSWijIleHPqn6bTSMYfPGAxRemAiq
3427SsMLpM+GYWZAg8Kz7VSsK4f0s4dW6i82QYFVk/+04N/0RUJ3A9RTloxSl8+aHT5M
3428F2x4LGr1eBgpz4UEsC5cJtkzA8OCM2a2TtNiuo/PtKzZx2TuvEk+Ub5Gn/lttZn8
3429m9z6o8n51D3vEIfHfXPyFre2+cz+Ao680kc0KP8PWlG89mhvMZ2VYGJG2T/Z6Ddp
3430j7aXM+jKCCjBTLMkLYaIuNO9//72kmBYsVgaBAMNYMBaBqQX1TOjwxbiBbv5fbJn
3431AgMBAAGjUzBRMB0GA1UdDgQWBBSniLAZD6er7hHpwg12hIX57PHb2TAfBgNVHSME
3432GDAWgBSniLAZD6er7hHpwg12hIX57PHb2TAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
3433SIb3DQEBCwUAA4IBAQBsR5VKflwEwRTNY1dobAWKS6kLTszpRFlQN2qBMTv+NhS0
3434i7mrNUzKadZkmlQuOMIhZl6gR4mB0XVPgkJKJ+ch8SfuaBW3Po4dTdrKfB6KCgCT
3435M54UB3QQAlAjpVhLCS7aCT8hgKEX1+1OD1SmBNQ/Jj9OOoKxVkq9prjSzILWpXeT
3436/OKKRqZ7tjG2jh55XPgE+GWLCfo3VsPqcleAoxQEWATryTF4fwKI9tuAgJ8ppN1M
34376UxJFatwx23InC/jVPR6wBu5h1SyCjIxuW/j8pgriTm8wR3XaTly49j6VQDH8KGh
3438yM+0UsZEWeI05Uq9c/Vs5TlJAcnvwJwxJqREhlHYMQA=
3439-----END CERTIFICATE-----
3440";
3441
3442 /// A file in the temp directory, removed when the test is done with it.
3443 ///
3444 /// `YT_CA_BUNDLE` names a path, so the thing under test reads one; there is
3445 /// nothing to inject. The name carries a
3446 /// [`unique::word`](crate::unique::word) because the test binary runs its
3447 /// tests in threads, and two of these writing one path would be two tests
3448 /// reading each other's bundle.
3449 #[cfg(feature = "tls")]
3450 struct TempPem(std::path::PathBuf);
3451
3452 #[cfg(feature = "tls")]
3453 impl TempPem {
3454 fn new(contents: &str) -> Self {
3455 let path = std::env::temp_dir()
3456 .join(format!("ytsaurus-rs-ca-{:x}.pem", crate::unique::word(0)));
3457 std::fs::write(&path, contents).expect("writes the bundle");
3458 Self(path)
3459 }
3460
3461 fn path(&self) -> &Path {
3462 &self.0
3463 }
3464
3465 /// The path as the refusals spell it, for asserting they name it.
3466 fn shown(&self) -> String {
3467 self.0.display().to_string()
3468 }
3469 }
3470
3471 #[cfg(feature = "tls")]
3472 impl Drop for TempPem {
3473 fn drop(&mut self) {
3474 std::fs::remove_file(&self.0).ok();
3475 }
3476 }
3477
3478 #[test]
3479 #[cfg(feature = "tls")]
3480 fn a_bundle_becomes_the_roots_and_its_private_key_is_left_alone() {
3481 // Two certificates and a key in one file: the shape of
3482 // `/etc/ssl/certs/ca-certificates.crt` next to a deployment that keeps
3483 // everything in one PEM. Only the certificates are roots.
3484 let file = TempPem::new(&format!("{CA_PEM}{KEY_PEM}{CA_PEM}"));
3485 let config = bundle(file.path()).expect("a bundle with certificates in it");
3486
3487 match config.root_certs() {
3488 ureq::tls::RootCerts::Specific(certs) => assert_eq!(certs.len(), 2),
3489 other => panic!("the bundle did not become the roots: {other:?}"),
3490 }
3491 }
3492
3493 #[test]
3494 #[cfg(feature = "tls")]
3495 fn a_bundle_that_parses_to_nothing_is_refused() {
3496 // Not "and then we quietly used Mozilla's roots": that answers a
3497 // deliberate request with `UnknownIssuer`, which is the failure the
3498 // variable exists to end, and names neither the file nor the reason.
3499 for (what, contents) in [
3500 ("a key and no certificate", KEY_PEM),
3501 ("an empty file", ""),
3502 ("the cluster's HTML login page", "<html>Sign in</html>\n"),
3503 ] {
3504 let file = TempPem::new(contents);
3505 let refusal = bundle(file.path()).expect_err(what);
3506
3507 assert!(refusal.contains(CA_BUNDLE), "{what}: {refusal}");
3508 assert!(refusal.contains(&file.shown()), "{what}: {refusal}");
3509 assert!(refusal.contains("no PEM certificates"), "{what}: {refusal}");
3510 }
3511 }
3512
3513 #[test]
3514 #[cfg(feature = "tls")]
3515 fn a_pkcs7_bundle_wearing_a_certificate_label_is_refused() {
3516 // The headline defect. PEM is an envelope: `parse_pem` splits and
3517 // base64-decodes and checks nothing, and `rustls` then discards what it
3518 // cannot parse *in silence* — so this was accepted, the root store came
3519 // out empty, and every request failed `UnknownIssuer` naming neither
3520 // the file nor the variable. Which is precisely the outcome
3521 // `YT_CA_BUNDLE` exists to end, arrived at through `YT_CA_BUNDLE`.
3522 let file = TempPem::new(REARMOURED_P7B);
3523 let refusal = bundle(file.path()).expect_err("a PKCS#7 blob is not a certificate");
3524
3525 assert!(refusal.contains(CA_BUNDLE), "{refusal}");
3526 assert!(refusal.contains(&file.shown()), "{refusal}");
3527 assert!(refusal.contains("not an X.509 certificate"), "{refusal}");
3528 assert!(refusal.contains("PKCS#7"), "{refusal}");
3529 }
3530
3531 #[test]
3532 #[cfg(feature = "tls")]
3533 fn one_good_certificate_does_not_excuse_the_rest_of_the_file() {
3534 // The truncation case: a real root beside two blocks that are not
3535 // certificates. Accepting it would silently trust one third of what the
3536 // caller wrote down, and the request that then failed would blame the
3537 // cluster.
3538 let file = TempPem::new(&format!("{CA_PEM}{REARMOURED_P7B}{REARMOURED_P7B}"));
3539 let refusal = bundle(file.path()).expect_err("two blocks are not certificates");
3540
3541 assert!(refusal.contains("2 of 3"), "{refusal}");
3542 assert!(refusal.contains(&file.shown()), "{refusal}");
3543 }
3544
3545 #[test]
3546 #[cfg(feature = "tls")]
3547 fn a_block_that_did_not_survive_the_envelope_refuses_the_file_too() {
3548 // The other half of the same truncation: a section that never decodes
3549 // at all. `parse_pem` yields `Err` for it and the roots that did parse
3550 // are still perfectly good — which is exactly the trap, because a store
3551 // that is quietly shorter than the file fails later, as `UnknownIssuer`
3552 // against a cluster that is not at fault.
3553 //
3554 // Ordinary bundles do not land here: a leading comment or a label
3555 // between blocks parses without complaint. Only damage does.
3556 for (what, body) in [
3557 (
3558 "corrupt base64",
3559 format!(
3560 "{CA_PEM}-----BEGIN CERTIFICATE-----\n!!!! not base64 !!!!\n\
3561 -----END CERTIFICATE-----\n{CA_PEM}"
3562 ),
3563 ),
3564 (
3565 "a file that stops mid-block",
3566 format!("{CA_PEM}-----BEGIN CERTIFICATE-----\nMIIB"),
3567 ),
3568 ] {
3569 let file = TempPem::new(&body);
3570 let refusal = bundle(file.path()).err().unwrap_or_else(|| {
3571 panic!("{what} should refuse the file rather than shorten the store")
3572 });
3573
3574 assert!(refusal.contains(&file.shown()), "{what}: {refusal}");
3575 assert!(refusal.contains("could not be read"), "{what}: {refusal}");
3576 }
3577 }
3578
3579 #[test]
3580 #[cfg(feature = "tls")]
3581 fn a_bundle_larger_than_any_bundle_is_refused_rather_than_held() {
3582 // Sized, not written: the cap is read off the file's metadata, so the
3583 // bytes are never touched — which is the whole point. A 512 MB file
3584 // cost 18.7 s and 1.27 GB of resident memory before this, for something
3585 // that was never going to parse.
3586 let file = TempPem::new("");
3587 std::fs::OpenOptions::new()
3588 .write(true)
3589 .open(file.path())
3590 .and_then(|f| f.set_len(MAX_BUNDLE_BYTES + 1))
3591 .expect("sizes the file");
3592
3593 let refusal = bundle(file.path()).expect_err("larger than any root bundle");
3594
3595 assert!(refusal.contains(CA_BUNDLE), "{refusal}");
3596 assert!(refusal.contains(&file.shown()), "{refusal}");
3597 assert!(refusal.contains("a few hundred kilobytes"), "{refusal}");
3598 }
3599
3600 #[test]
3601 #[cfg(feature = "tls")]
3602 fn a_bundle_that_is_not_a_regular_file_is_refused_rather_than_read() {
3603 // A directory, and by the same check a FIFO — which is the one that
3604 // matters: opening a named pipe for reading blocks until someone writes
3605 // to it, `Client::new` is infallible, and the client's global timeout
3606 // covers requests rather than files. Nothing above this would ever have
3607 // ended the wait.
3608 let refusal = bundle(&std::env::temp_dir()).expect_err("a directory is not a bundle");
3609
3610 assert!(refusal.contains(CA_BUNDLE), "{refusal}");
3611 assert!(refusal.contains("not a regular file"), "{refusal}");
3612 }
3613
3614 #[test]
3615 #[cfg(feature = "tls")]
3616 fn a_bundle_beats_whatever_the_build_would_have_trusted() {
3617 // The precedence, and the whole reason the feature is not simply
3618 // "trust the OS": a bundle is the more specific answer and the one the
3619 // caller went out of their way to give. With `platform-verifier` off
3620 // this says the bundle beats the Mozilla roots; with it on, that it
3621 // beats the platform verifier too, which is the case worth pinning.
3622 let file = TempPem::new(CA_PEM);
3623 let chosen = roots_for(Some(file.path()))
3624 .expect("a readable bundle")
3625 .expect("some roots");
3626
3627 assert!(
3628 matches!(chosen.root_certs(), ureq::tls::RootCerts::Specific(_)),
3629 "{:?}",
3630 chosen.root_certs()
3631 );
3632 }
3633
3634 /// `heavy_base` under the default rules — a name may not leave the domain.
3635 fn routed(configured: &str, host: &str) -> Option<String> {
3636 heavy_base(configured, host, &HeavyHosts::SameDomain).ok()
3637 }
3638
3639 /// `heavy_base` with the domain rule relaxed.
3640 fn routed_anywhere(configured: &str, host: &str) -> Option<String> {
3641 heavy_base(configured, host, &HeavyHosts::Anywhere).ok()
3642 }
3643
3644 #[test]
3645 fn a_host_from_the_cluster_keeps_the_scheme_it_was_reached_by() {
3646 // `/hosts` answers with names, not URLs. A cluster reached over TLS
3647 // serves heavy commands over TLS; one reached over plain HTTP — a
3648 // local install, a tunnel — would refuse the handshake.
3649 assert_eq!(
3650 routed("https://cluster.example.net", "n0132-sas.example.net"),
3651 Some("https://n0132-sas.example.net".to_owned())
3652 );
3653 assert_eq!(
3654 routed("http://cluster.example.net", "n0132-sas.example.net"),
3655 Some("http://n0132-sas.example.net".to_owned())
3656 );
3657 // A port of its own travels with the name.
3658 assert_eq!(
3659 routed(
3660 "http://cluster.example.net:8000",
3661 "n0132-sas.example.net:9013"
3662 ),
3663 Some("http://n0132-sas.example.net:9013".to_owned())
3664 );
3665 // And the configured one carries through when the name has none, which
3666 // is the usual case: the coordinator lists bare host names unless its
3667 // `ShowPorts` config says otherwise, and a cluster reached at :8000 has
3668 // no reason to think its heavy proxies answer on 80.
3669 assert_eq!(
3670 routed("http://cluster.example.net:8000", "n0132-sas.example.net"),
3671 Some("http://n0132-sas.example.net:8000".to_owned())
3672 );
3673 assert_eq!(
3674 routed("https://cluster.example.net:8443", "n0132-sas.example.net"),
3675 Some("https://n0132-sas.example.net:8443".to_owned())
3676 );
3677 }
3678
3679 #[test]
3680 #[cfg(feature = "tls")]
3681 fn a_named_bundle_that_will_not_parse_refuses_the_choice_itself() {
3682 // `roots_for` is where the fall-through would hide: turning
3683 // `bundle(path).map(Some)` into `Ok(bundle(path).ok())` makes an
3684 // unreadable bundle mean "nothing was named", which is Mozilla's roots
3685 // and the silent `UnknownIssuer` all over again. It is also, verbatim,
3686 // what the patch proposed in the issue did.
3687 let file = TempPem::new(KEY_PEM);
3688 let refusal = roots_for(Some(file.path())).expect_err("a key is not a root");
3689
3690 assert!(refusal.contains(CA_BUNDLE), "{refusal}");
3691 assert!(refusal.contains(&file.shown()), "{refusal}");
3692 }
3693
3694 #[test]
3695 #[cfg(feature = "tls")]
3696 fn a_variable_that_names_nothing_is_not_a_bundle() {
3697 // `export YT_CA_BUNDLE=` is how a shell profile turns one off. Read as
3698 // a path it would be a refusal on every request.
3699 for named in [None, Some(Path::new("")), Some(Path::new(" "))] {
3700 let chosen = roots_for(named).expect("no bundle was named");
3701 let roots = chosen.as_ref().map(ureq::tls::TlsConfig::root_certs);
3702
3703 // With `platform-verifier` on, an unset variable is what asks for
3704 // the operating system's own trust store.
3705 #[cfg(feature = "platform-verifier")]
3706 assert!(
3707 matches!(roots, Some(ureq::tls::RootCerts::PlatformVerifier)),
3708 "{roots:?}"
3709 );
3710
3711 // Without it, nothing is configured at all and `ureq` keeps the
3712 // Mozilla bundle it compiles in.
3713 #[cfg(not(feature = "platform-verifier"))]
3714 assert!(roots.is_none(), "{roots:?}");
3715 }
3716 }
3717
3718 #[test]
3719 fn a_hosts_answer_cannot_send_the_token_somewhere_else() {
3720 // The four rows of the table in #30, each measured against the client
3721 // before this check existed. The `/hosts` body decides where every
3722 // heavy command goes, and a heavy command carries the caller's OAuth
3723 // token — so on a plain-http base, forging this body is exactly as easy
3724 // as forging a `Location` header, which this client already refuses to
3725 // follow.
3726
3727 // 1. The scheme downgrade. `http://n0132` from an `https://` client
3728 // used to strip TLS and put the token on the wire in cleartext.
3729 assert_eq!(routed("https://cluster.example.net", "http://n0132"), None);
3730 assert_eq!(
3731 routed("https://cluster.example.net", "https://n0132.example.net"),
3732 None,
3733 "a name that spells its own scheme is not a name"
3734 );
3735
3736 // 2. The userinfo trick. `real@evil` is a URL whose *host* is `evil`
3737 // and whose reassuring half is thrown away by every parser.
3738 assert_eq!(
3739 routed(
3740 "https://cluster.example.net",
3741 "real.example.net@evil.example.net"
3742 ),
3743 None
3744 );
3745
3746 // 3. A path, a query or a fragment: none of them belongs in a host
3747 // name, and each is a way to make one read as another.
3748 for shape in [
3749 "n0132.example.net/../../evil",
3750 "n0132.example.net/api",
3751 "n0132.example.net?x=1",
3752 "n0132.example.net#f",
3753 "n0132 .example.net",
3754 "n0132.example.net\tn0133.example.net",
3755 "",
3756 " ",
3757 ] {
3758 assert_eq!(
3759 routed("https://cluster.example.net", shape),
3760 None,
3761 "{shape:?} was accepted as a host name"
3762 );
3763 }
3764
3765 // Padding around the name is normalised rather than refused, which is
3766 // what the blank-name filter used to do on its own — and what makes the
3767 // empty entries above empty.
3768 assert_eq!(
3769 routed("https://cluster.example.net", " \tn0132.example.net\n"),
3770 Some("https://n0132.example.net".to_owned())
3771 );
3772
3773 // 4. Somewhere else entirely. The name has to sit under the domain of
3774 // the address the caller chose.
3775 for elsewhere in [
3776 "n0132-sas.somewhere-else.net",
3777 "cluster.example.net.evil.com",
3778 "evil.com",
3779 "notexample.net",
3780 ] {
3781 assert_eq!(
3782 routed("https://cluster.example.net", elsewhere),
3783 None,
3784 "{elsewhere} was followed"
3785 );
3786 }
3787 }
3788
3789 #[test]
3790 #[cfg(feature = "tls")]
3791 fn a_bundle_that_cannot_be_read_is_refused_rather_than_ignored() {
3792 let missing = std::env::temp_dir().join("ytsaurus-rs-no-such-bundle.pem");
3793 let refusal = bundle(&missing).expect_err("nothing to read");
3794
3795 assert!(refusal.contains(CA_BUNDLE), "{refusal}");
3796 assert!(refusal.contains("could not be read"), "{refusal}");
3797 }
3798
3799 #[test]
3800 #[cfg(feature = "tls")]
3801 fn the_variable_is_spelled_the_way_the_documentation_spells_it() {
3802 // The one assertion that is about the name rather than about what the
3803 // name does. Everything else here compares against the constant, so
3804 // renaming its *value* would leave the suite green and the crate
3805 // reading a variable nobody sets — the README, the crate docs, the
3806 // CHANGELOG and the `yt` CLI all say `YT_CA_BUNDLE`.
3807 assert_eq!(CA_BUNDLE, "YT_CA_BUNDLE");
3808
3809 let missing = std::env::temp_dir().join("ytsaurus-rs-no-such-bundle.pem");
3810 let refusal = bundle(&missing).expect_err("nothing to read");
3811 assert!(refusal.contains("YT_CA_BUNDLE"), "{refusal}");
3812 }
3813
3814 #[test]
3815 #[cfg(feature = "tls")]
3816 fn a_named_bundle_reaches_the_agent_that_is_built_from_it() {
3817 // The other half of the chain: `roots_for` choosing correctly is worth
3818 // nothing if `build_agent` drops the answer on the floor. Nothing else
3819 // reads the agent's own configuration back.
3820 let file = TempPem::new(CA_PEM);
3821 let (agent, refused) = build_agent(Duration::from_secs(1), Some(file.path()));
3822
3823 assert!(refused.is_none(), "{refused:?}");
3824 assert!(
3825 matches!(
3826 agent.config().tls_config().root_certs(),
3827 ureq::tls::RootCerts::Specific(_)
3828 ),
3829 "{:?}",
3830 agent.config().tls_config().root_certs()
3831 );
3832 }
3833
3834 #[test]
3835 fn the_domain_a_discovered_host_has_to_share() {
3836 // The configured host itself, and anything under its parent domain.
3837 assert!(same_domain("cluster.example.net", "cluster.example.net"));
3838 assert!(same_domain("cluster.example.net", "n0132-sas.example.net"));
3839 assert!(same_domain(
3840 "cluster.example.net",
3841 "n0132-sas.cluster.example.net"
3842 ));
3843 assert!(same_domain("cluster.example.net", "example.net"));
3844 // Case is not part of a host name.
3845 assert!(same_domain("Cluster.Example.NET", "n0132-sas.example.net"));
3846
3847 // Never below two labels, or a client pointed at `example.net` would
3848 // follow anything at all under `.net`.
3849 assert!(!same_domain("example.net", "n0132-sas.other.net"));
3850 assert!(same_domain("example.net", "n0132-sas.example.net"));
3851
3852 // A literal address has no domain to share, so it admits only itself.
3853 assert!(same_domain("10.0.0.7", "10.0.0.7"));
3854 assert!(!same_domain("10.0.0.7", "10.0.0.8"));
3855 assert!(!same_domain("10.0.0.7", "n0132-sas.example.net"));
3856 assert!(!same_domain("cluster.example.net", "10.0.0.7"));
3857
3858 // Suffix, not substring: the trap this rule exists to avoid.
3859 assert!(!same_domain("cluster.example.net", "evil-example.net"));
3860 assert!(!same_domain("cluster.example.net", "example.net.evil.com"));
3861 }
3862
3863 #[test]
3864 fn a_bare_cluster_name_is_matched_as_a_label_and_not_as_a_domain() {
3865 // `YT_PROXY=hume` — a cluster name with no dots — is the ordinary
3866 // spelling, and `Transport::new` supports it on purpose. It has no
3867 // leftmost label to take off, so the parent-domain rule degenerated to
3868 // "the name itself" and refused the real answer of a real installation:
3869 // `["n0008-sas.hume.yt.example.net"]` was declined in full, the state
3870 // settled as "this cluster has no heavy proxies", and it is never asked
3871 // again — leaving the operator with the cluster error from #30 and
3872 // nothing to connect it to.
3873 assert!(same_domain("hume", "n0008-sas.hume.yt.example.net"));
3874 // The documentation's own example shape, which is the same rule.
3875 assert!(same_domain("cluster-name", "n0008-sas.cluster-name"));
3876 // And Kubernetes, where a service addressed by its short name answers
3877 // with the fully qualified one.
3878 assert!(same_domain(
3879 "yt-http-proxy",
3880 "yt-http-proxy-0.yt-http-proxy.yt.svc.cluster.local"
3881 ));
3882
3883 // Not the leftmost label, which is where the *proxy's* own name goes:
3884 // a name that puts the cluster's name there is claiming to be the
3885 // cluster, in somebody else's zone.
3886 assert!(!same_domain("hume", "hume.evil.com"));
3887 // A whole label, not a prefix of one.
3888 assert!(!same_domain("hume", "n0008-sas.humeier.yt.example.net"));
3889 assert!(!same_domain("hume", "evil.com"));
3890 // The configured name itself is still the configured name.
3891 assert!(same_domain("hume", "hume"));
3892
3893 // And through `heavy_base`, which is where the base URL
3894 // `Transport::new` builds for a bare name meets the rule: `Client::new
3895 // ("hume")` is `https://hume`, and the answer above is what a real
3896 // installation returns for it.
3897 assert_eq!(
3898 routed("https://hume", "n0008-sas.hume.yt.example.net"),
3899 Some("https://n0008-sas.hume.yt.example.net".to_owned())
3900 );
3901 }
3902
3903 #[test]
3904 #[cfg(feature = "tls")]
3905 fn a_bundle_the_agent_could_not_honour_is_carried_out_of_the_constructor() {
3906 // `build_agent` has no `Result` to fail into, so the one thing it must
3907 // do with a refusal is hand it back. Swallowing it — `Err(_) => {}` —
3908 // leaves a client that looks built, trusts Mozilla's roots, and never
3909 // mentions the file it was told to use.
3910 let file = TempPem::new(KEY_PEM);
3911 let (_, refused) = build_agent(Duration::from_secs(1), Some(file.path()));
3912
3913 let refusal = refused.expect("the refusal reaches the transport");
3914 assert!(refusal.contains(CA_BUNDLE), "{refusal}");
3915 assert!(refusal.contains(&file.shown()), "{refusal}");
3916 }
3917
3918 #[test]
3919 #[cfg(feature = "tls")]
3920 fn the_der_check_takes_certificates_and_leaves_everything_else() {
3921 use ureq::tls::{Certificate, PemItem, parse_pem};
3922
3923 let der = |pem: &str| {
3924 parse_pem(pem.as_bytes())
3925 .find_map(|item| match item {
3926 Ok(PemItem::Certificate(cert)) => Some(Certificate::to_owned(&cert)),
3927 _ => None,
3928 })
3929 .expect("one CERTIFICATE block")
3930 };
3931
3932 assert!(is_x509(der(CA_PEM).der()));
3933 assert!(!is_x509(der(REARMOURED_P7B).der()));
3934
3935 // Nothing, a truncated certificate, and one with a byte glued on the
3936 // end — the three ways a length can lie.
3937 let good = der(CA_PEM);
3938 assert!(!is_x509(&[]));
3939 assert!(!is_x509(&good.der()[..good.der().len() - 1]));
3940 assert!(!is_x509(&[good.der(), b"\x00"].concat()));
3941 }
3942
3943 #[test]
3944 #[cfg(feature = "tls")]
3945 fn a_refused_bundle_is_reported_instead_of_the_first_request() {
3946 // The refusal is discovered while the agent is being built, where
3947 // there is nothing to fail; it waits here for something that is.
3948 let mut transport =
3949 Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
3950 transport.tls_refused = Some("YT_CA_BUNDLE names /etc/no-such-file".to_owned());
3951
3952 let error = transport.unusable(&transport.base).expect("a refusal");
3953 assert!(matches!(error, ClientError::Config(_)), "{error}");
3954 // And against the address a heavy command would actually be dialled at
3955 // (#38): a discovered https:// heavy proxy is refused by the same
3956 // bundle, a plain-http one is not.
3957 assert!(
3958 transport
3959 .unusable("https://n0132-sas.example.net")
3960 .is_some()
3961 );
3962 assert!(transport.unusable("http://n0132-sas.example.net").is_none());
3963 assert!(error.to_string().contains("YT_CA_BUNDLE"), "{error}");
3964 }
3965
3966 #[test]
3967 fn a_refused_bundle_does_not_stop_a_cluster_reached_over_plain_http() {
3968 // No handshake, so nothing the bundle would have configured. A stale
3969 // variable in a shell profile is not a reason to refuse a local
3970 // cluster.
3971 let mut transport = transport(None);
3972 transport.tls_refused = Some("YT_CA_BUNDLE names /etc/no-such-file".to_owned());
3973
3974 assert!(transport.unusable(&transport.base).is_none());
3975 }
3976
3977 /// A transport that must refuse every request before it opens a socket,
3978 /// in **either** feature configuration.
3979 ///
3980 /// With `tls` on that is a `YT_CA_BUNDLE` that could not be honoured; with
3981 /// it off, an `https://` proxy in a build that has no handshake at all.
3982 /// Both are [`Transport::unusable`], which is the thing the two tests below
3983 /// pin — and the base is a closed port on the loopback so that a
3984 /// `Transport` which *did* reach the network fails fast and loudly rather
3985 /// than resolving a name that might exist.
3986 fn cannot_send() -> Transport {
3987 let mut transport = Transport::new("https://127.0.0.1:1", None, Duration::from_millis(250));
3988 transport.set_retries(RetryPolicy::none().quiet());
3989 #[cfg(feature = "tls")]
3990 {
3991 transport.tls_refused = Some(format!("{CA_BUNDLE} names /etc/no-such-file"));
3992 }
3993 transport
3994 }
3995
3996 #[test]
3997 fn a_command_is_refused_before_a_socket_is_opened() {
3998 // `dispatch` is the seam every command goes through — `send`, `open`
3999 // and `upload` all reach it — so its guard is the one that decides
4000 // whether an unusable transport explains itself or fails at the
4001 // handshake with a sentence about the network. Removing it leaves the
4002 // suite green today; this is what says otherwise.
4003 let transport = cannot_send();
4004 let error = transport
4005 .dispatch(
4006 &transport.base,
4007 Method::Get,
4008 "get_supported_features",
4009 &map::<&str>([]),
4010 Outgoing::Empty,
4011 false,
4012 )
4013 .expect_err("a transport that cannot be used");
4014
4015 assert!(matches!(error, ClientError::Config(_)), "{error}");
4016 }
4017
4018 #[test]
4019 fn the_hosts_lookup_is_refused_before_a_socket_is_opened() {
4020 // `/hosts` is not a command and gets its request built by hand, which
4021 // is how it once came to carry no token; the guard is one of the four
4022 // things `fetch` exists to stop it missing again.
4023 let error = cannot_send()
4024 .fetch("/hosts", "hosts")
4025 .expect_err("a transport that cannot be used");
4026
4027 assert!(matches!(error, ClientError::Config(_)), "{error}");
4028 }
4029
4030 #[test]
4031 fn an_installation_that_really_does_answer_elsewhere_can_say_so() {
4032 // The opt-in, for a cluster fronted by a vanity address or one whose
4033 // data proxies live under a separate zone. It relaxes the domain and
4034 // nothing else: the scheme still comes from the configured address, and
4035 // a name carrying furniture is still not a name.
4036 assert_eq!(
4037 routed_anywhere(
4038 "https://cluster.example.net",
4039 "n0132-sas.somewhere-else.net"
4040 ),
4041 Some("https://n0132-sas.somewhere-else.net".to_owned())
4042 );
4043 assert_eq!(
4044 routed_anywhere("https://cluster.example.net", "http://n0132"),
4045 None,
4046 "the escape hatch is about the domain, not about the scheme"
4047 );
4048 assert_eq!(
4049 routed_anywhere(
4050 "https://cluster.example.net",
4051 "real.example.net@evil.example.net"
4052 ),
4053 None
4054 );
4055 // Nor about blank entries. With the domain rule relaxed, this is the
4056 // only thing standing between an empty name and the base URL
4057 // `https://:8000`.
4058 for blank in ["", " ", "\t\n"] {
4059 assert_eq!(
4060 routed_anywhere("https://cluster.example.net:8000", blank),
4061 None,
4062 "{blank:?} was accepted as a host name"
4063 );
4064 }
4065 }
4066
4067 #[test]
4068 fn a_list_written_out_by_hand_is_the_third_answer() {
4069 // The domain rule is a typo guard, not a boundary: on a shared platform
4070 // a parent domain is shared with every other tenant. A list somebody
4071 // wrote on purpose is the version that is a boundary — and the only
4072 // cure for a domain rule that misses by one label that is not "take the
4073 // rule away entirely".
4074 let only = HeavyHosts::Only(vec![
4075 "n0132-sas.somewhere-else.net".to_owned(),
4076 "n0133-sas.somewhere-else.net:9013".to_owned(),
4077 ]);
4078
4079 assert_eq!(
4080 heavy_base(
4081 "https://cluster.example.net:8443",
4082 "n0132-sas.somewhere-else.net",
4083 &only
4084 ),
4085 Ok("https://n0132-sas.somewhere-else.net:8443".to_owned()),
4086 "a listed name outside the domain is still allowed"
4087 );
4088 // Case is not part of a host name, and a port is compared only where
4089 // both sides name one — `/hosts` usually names none.
4090 assert_eq!(
4091 heavy_base(
4092 "https://cluster.example.net:8443",
4093 "N0133-SAS.somewhere-else.net:9013",
4094 &only
4095 ),
4096 Ok("https://N0133-SAS.somewhere-else.net:9013".to_owned()),
4097 );
4098 assert_eq!(
4099 heavy_base(
4100 "https://cluster.example.net:8443",
4101 "n0133-sas.somewhere-else.net",
4102 &only
4103 ),
4104 Ok("https://n0133-sas.somewhere-else.net:8443".to_owned()),
4105 "a listed port must not be a requirement on an answer that has none"
4106 );
4107 assert_eq!(
4108 heavy_base(
4109 "https://cluster.example.net:8443",
4110 "n0133-sas.somewhere-else.net:9014",
4111 &only
4112 ),
4113 Err(Declined::Elsewhere),
4114 "a port both sides name has to be the same port"
4115 );
4116 // Everything else is refused, including a name the domain rule would
4117 // have allowed: this narrows, it does not widen.
4118 assert_eq!(
4119 heavy_base(
4120 "https://cluster.example.net",
4121 "n0134-sas.example.net",
4122 &only
4123 ),
4124 Err(Declined::Elsewhere)
4125 );
4126 assert_eq!(
4127 heavy_base("https://cluster.example.net", "http://n0132", &only),
4128 Err(Declined::Malformed),
4129 "a list is about which names, not about what a name may look like"
4130 );
4131 // An empty list admits nothing, which is a way of turning routing off.
4132 assert_eq!(
4133 heavy_base(
4134 "https://cluster.example.net",
4135 "n0132-sas.example.net",
4136 &HeavyHosts::Only(Vec::new())
4137 ),
4138 Err(Declined::Elsewhere)
4139 );
4140 }
4141
4142 #[test]
4143 fn a_named_domain_widens_the_rule_without_removing_it() {
4144 // The shape a large installation has: the cluster is addressed as
4145 // `cluster.example.net` and
4146 // `/hosts` answers seventy-nine names under `proxy-zone.net`. The two
4147 // settings that existed were writing all seventy-nine down — stale the
4148 // moment one rotates — and taking the rule away.
4149 let under = HeavyHosts::Under {
4150 domains: vec!["proxy-zone.net".to_owned()],
4151 ignored: Vec::new(),
4152 };
4153 let configured = "https://cluster.example.net";
4154
4155 assert_eq!(
4156 heavy_base(configured, "n0132-sas.rack7.proxy-zone.net", &under),
4157 Ok("https://n0132-sas.rack7.proxy-zone.net".to_owned())
4158 );
4159 // Case is not part of a host name here either.
4160 assert_eq!(
4161 heavy_base(configured, "N0133-SAS.rack7.PROXY-ZONE.net", &under),
4162 Ok("https://N0133-SAS.rack7.PROXY-ZONE.net".to_owned())
4163 );
4164 // The domain itself, not only what is under it.
4165 assert_eq!(
4166 heavy_base(configured, "proxy-zone.net", &under),
4167 Ok("https://proxy-zone.net".to_owned())
4168 );
4169 // It widens rather than replaces: the configured address's own domain
4170 // still admits its own proxies.
4171 assert_eq!(
4172 heavy_base(configured, "n0008-sas.example.net", &under),
4173 Ok("https://n0008-sas.example.net".to_owned())
4174 );
4175 // And it is still a rule. A neighbour that only looks like the domain
4176 // is not under it, and everything else is where it was.
4177 for elsewhere in [
4178 "proxy-zone.net.evil.com",
4179 "evil-proxy-zone.net",
4180 "n0132-sas.somewhere-else.net",
4181 ] {
4182 assert_eq!(
4183 heavy_base(configured, elsewhere, &under),
4184 Err(Declined::Elsewhere),
4185 "{elsewhere}"
4186 );
4187 }
4188 // A name that is not a name is refused before any of this: naming a
4189 // domain says which hosts, not what a host may look like.
4190 assert_eq!(
4191 heavy_base(configured, "http://n0132-sas.rack7.proxy-zone.net", &under),
4192 Err(Declined::Malformed)
4193 );
4194 // An empty list is exactly the default, so a variable set to nothing
4195 // cannot quietly widen anything.
4196 assert_eq!(
4197 heavy_base(
4198 configured,
4199 "n0132-sas.rack7.proxy-zone.net",
4200 &HeavyHosts::Under {
4201 domains: Vec::new(),
4202 ignored: Vec::new(),
4203 }
4204 ),
4205 Err(Declined::Elsewhere)
4206 );
4207 }
4208
4209 #[test]
4210 fn a_refusal_names_the_domains_that_were_added() {
4211 // The refusal is the whole of what an operator has to work from, and
4212 // one that named only the configured address would read as though the
4213 // list had been ignored.
4214 let under = HeavyHosts::Under {
4215 domains: vec!["proxy-zone.net".to_owned()],
4216 ignored: Vec::new(),
4217 };
4218 let because = Declined::Elsewhere.because(&under, "https://cluster.example.net");
4219
4220 assert!(because.contains("cluster.example.net"), "{because}");
4221 assert!(because.contains("proxy-zone.net"), "{because}");
4222
4223 // With nothing added there is nothing extra to name, and the sentence
4224 // is the one the default rule has always given.
4225 assert_eq!(
4226 Declined::Elsewhere.because(
4227 &HeavyHosts::Under {
4228 domains: Vec::new(),
4229 ignored: Vec::new(),
4230 },
4231 "https://cluster.example.net"
4232 ),
4233 Declined::Elsewhere.because(&HeavyHosts::SameDomain, "https://cluster.example.net")
4234 );
4235 }
4236
4237 #[test]
4238 fn a_written_domain_is_normalised_the_way_it_gets_written() {
4239 // These arrive from `YT_HEAVY_PROXY_DOMAINS` and from configuration
4240 // files as often as from a literal, so every spelling a person uses for
4241 // one domain has to reach the same rule. The wildcard is the one that
4242 // matters most: `*.proxy-zone.net` is how a zone is described in prose
4243 // and in a certificate, and kept verbatim it would test
4244 // `ends_with(".*.proxy-zone.net")` and match nothing at all — the
4245 // feature a silent no-op, and the heavy commands still failing.
4246 //
4247 // And they are one domain, not six: a refusal that read `not under
4248 // cluster.example.net or under proxy-zone.net, proxy-zone.net,
4249 // proxy-zone.net` looks like a bug in the client to the one person it
4250 // is written for.
4251 let mut transport = Transport::new("https://cluster.example.net", None, HOSTS_TIMEOUT);
4252 transport.set_heavy_proxies_under(vec![
4253 " .Proxy-Zone.net. ".to_owned(),
4254 "*.proxy-zone.net".to_owned(),
4255 "https://proxy-zone.net".to_owned(),
4256 "proxy-zone.net:443".to_owned(),
4257 "https://proxy-zone.net./".to_owned(),
4258 "proxy-zone.net.:443".to_owned(),
4259 ]);
4260
4261 assert_eq!(
4262 transport.heavy_hosts_debug(),
4263 r#"Under { domains: ["proxy-zone.net"], ignored: [] }"#
4264 );
4265 assert_eq!(
4266 heavy_base(
4267 &transport.base,
4268 "n0132-sas.rack7.proxy-zone.net",
4269 &transport.hosts
4270 ),
4271 Ok("https://n0132-sas.rack7.proxy-zone.net".to_owned()),
4272 );
4273 }
4274
4275 #[test]
4276 fn an_entry_that_is_not_a_domain_is_dropped_rather_than_believed() {
4277 // A single label is the dangerous one: `net` is a plausible typo for a
4278 // real domain, and honoured as a suffix it would admit every `.net`
4279 // host `/hosts` could name — `with_heavy_proxies_anywhere` by accident.
4280 // It is kept aside rather than forgotten, because a setting that
4281 // changes nothing and says nothing is indistinguishable from one this
4282 // client never read. An entry that is nothing at all is a trailing
4283 // comma, and nobody needs to hear about it.
4284 let mut transport = Transport::new("https://cluster.example.net", None, HOSTS_TIMEOUT);
4285 transport.set_heavy_proxies_under(vec![
4286 "net".to_owned(),
4287 " ".to_owned(),
4288 String::new(),
4289 ".".to_owned(),
4290 "*".to_owned(),
4291 ]);
4292
4293 assert_eq!(
4294 transport.heavy_hosts_debug(),
4295 r#"Under { domains: [], ignored: ["net"] }"#
4296 );
4297 // And it is in the refusal, which is the only place an operator looks.
4298 let because = Declined::Elsewhere.because(&transport.hosts, &transport.base);
4299 assert!(because.contains("ignored, not a domain: net"), "{because}");
4300 // And with everything dropped the rule is exactly the default: the
4301 // configured address's own domain admits its own proxies, and nothing
4302 // else is admitted at all.
4303 assert_eq!(
4304 heavy_base(&transport.base, "n0132-sas.example.net", &transport.hosts),
4305 Ok("https://n0132-sas.example.net".to_owned())
4306 );
4307 assert_eq!(
4308 heavy_base(
4309 &transport.base,
4310 "n0132-sas.rack7.proxy-zone.net",
4311 &transport.hosts
4312 ),
4313 Err(Declined::Elsewhere)
4314 );
4315 }
4316
4317 #[test]
4318 fn an_added_domain_carries_the_configured_port_and_keeps_a_named_one() {
4319 // Nothing about naming a domain changes where the port comes from: the
4320 // configured address's, unless `/hosts` named one itself.
4321 let under = HeavyHosts::Under {
4322 domains: vec!["proxy-zone.net".to_owned()],
4323 ignored: Vec::new(),
4324 };
4325
4326 assert_eq!(
4327 heavy_base(
4328 "https://cluster.example.net:8443",
4329 "n0132-sas.rack7.proxy-zone.net",
4330 &under
4331 ),
4332 Ok("https://n0132-sas.rack7.proxy-zone.net:8443".to_owned())
4333 );
4334 assert_eq!(
4335 heavy_base(
4336 "https://cluster.example.net:8443",
4337 "n0132-sas.rack7.proxy-zone.net:9013",
4338 &under
4339 ),
4340 Ok("https://n0132-sas.rack7.proxy-zone.net:9013".to_owned())
4341 );
4342 }
4343
4344 #[test]
4345 fn a_dotless_configured_name_keeps_its_label_rule_when_a_domain_is_added() {
4346 // `YT_PROXY=hume` is matched as a *label* of the discovered name rather
4347 // than as a domain — see `same_domain` — and adding a domain must not
4348 // cost that: `Under` is the label rule plus the domains, not instead of
4349 // them. Reachable without a resolver search list now that
4350 // `YT_PROXY_SUFFIX` exists, which is what makes this worth pinning.
4351 let under = HeavyHosts::Under {
4352 domains: vec!["proxy-zone.net".to_owned()],
4353 ignored: Vec::new(),
4354 };
4355
4356 assert_eq!(
4357 heavy_base("https://hume", "n0008-sas.hume.yt.example.net", &under),
4358 Ok("https://n0008-sas.hume.yt.example.net".to_owned()),
4359 "the label rule still applies"
4360 );
4361 assert_eq!(
4362 heavy_base("https://hume", "n0132-sas.rack7.proxy-zone.net", &under),
4363 Ok("https://n0132-sas.rack7.proxy-zone.net".to_owned()),
4364 "and the added domain applies beside it"
4365 );
4366 assert_eq!(
4367 heavy_base("https://hume", "hume.evil.com", &under),
4368 Err(Declined::Elsewhere),
4369 "and neither admits the cluster's name in a host's position"
4370 );
4371 }
4372
4373 #[test]
4374 fn a_bracketed_address_has_to_hold_an_ipv6_literal() {
4375 // A bare IPv6 literal is not a valid URL authority — bracketed, it is —
4376 // and an unbracketed second colon means this is not one host and one
4377 // port.
4378 assert_eq!(
4379 routed_anywhere("http://[2a02:6b8::1]:8000", "[2a02:6b8::2]:9013"),
4380 Some("http://[2a02:6b8::2]:9013".to_owned())
4381 );
4382 assert_eq!(
4383 routed_anywhere("http://[2a02:6b8::1]:8000", "[2a02:6b8::2]"),
4384 Some("http://[2a02:6b8::2]:8000".to_owned()),
4385 "the configured port carries through a bracketed name too"
4386 );
4387 assert_eq!(
4388 routed_anywhere("http://cluster.example.net", "2a02:6b8::2"),
4389 None
4390 );
4391 assert_eq!(
4392 routed_anywhere("http://cluster.example.net", "n0132:9013:9014"),
4393 None
4394 );
4395
4396 // The shape that made the brackets worth checking rather than merely
4397 // counting colons. Probed against `ureq` 3.3: this parses with the host
4398 // `[n0132.example.com]` — brackets are only stripped for something that
4399 // is an IPv6 literal — so no DNS will ever answer it. The token stays
4400 // put, which is the reason the second-colon rule waved it through, and
4401 // the cost is worse than a leak of nothing: the address is remembered,
4402 // every heavy command fails resolving it, and the failures repeat for
4403 // as long as the client lives.
4404 for shape in [
4405 "[n0132.example.com]evil.attacker.com",
4406 "[n0132.example.com]",
4407 "[n0132.example.com]:9013",
4408 "[2a02:6b8::2]junk",
4409 "[2a02:6b8::2]:junk",
4410 "[2a02:6b8::2]:",
4411 "[2a02:6b8::2",
4412 "[]",
4413 "[]:9013",
4414 // A port is digits, on either shape of name.
4415 "n0132.example.net:",
4416 "n0132.example.net:90a3",
4417 ":9013",
4418 ] {
4419 assert_eq!(
4420 routed_anywhere("http://cluster.example.net", shape),
4421 None,
4422 "{shape:?} was accepted as a host name"
4423 );
4424 }
4425 }
4426
4427 #[test]
4428 fn a_routed_failure_names_the_host_it_went_to() {
4429 // The report a caller gets otherwise is about an address that appears
4430 // nowhere in their own code: the client chose it, from a list the
4431 // cluster gave it, and then said nothing about the choice.
4432 let failed = routed_to(
4433 ClientError::Http {
4434 command: "write_table".to_owned(),
4435 status: 502,
4436 body: String::new(),
4437 },
4438 "https://n0132-sas.example.net:9013",
4439 );
4440
4441 assert!(
4442 failed
4443 .to_string()
4444 .starts_with("write_table at n0132-sas.example.net:9013:"),
4445 "{failed}"
4446 );
4447
4448 // Every shape that carries a command gets the same treatment; the ones
4449 // that do not are left exactly as they were.
4450 let local = routed_to(
4451 ClientError::Config("no proxy".to_owned()),
4452 "https://n0132-sas.example.net:9013",
4453 );
4454 assert_eq!(local.to_string(), "no proxy");
4455 }
4456
4457 #[test]
4458 fn a_cluster_on_loopback_is_not_asked_where_its_heavy_proxies_are() {
4459 // The address a proxy publishes for itself is its own. Behind a port
4460 // mapping or an SSH tunnel — which is what reaching a cluster at
4461 // `localhost` means — that address is not reachable from here, so
4462 // following it would send every upload nowhere.
4463 for local in [
4464 "http://localhost:8000",
4465 "http://LOCALHOST",
4466 "http://127.0.0.1:8000",
4467 "http://127.99.1.4",
4468 "https://[::1]:443",
4469 "http://0.0.0.0:8000",
4470 ] {
4471 assert!(is_local(local), "{local}");
4472 }
4473
4474 for remote in [
4475 "https://cluster.example.net",
4476 "http://cluster.example.net:8000",
4477 "https://10.0.0.7",
4478 "https://[2a02:6b8::1]:443",
4479 // The one that matters most: a host merely *named* after the
4480 // local one is somebody else's machine.
4481 "https://localhost.example.net",
4482 ] {
4483 assert!(!is_local(remote), "{remote}");
4484 }
4485 }
4486
4487 #[test]
4488 fn the_host_is_read_out_of_the_address_without_its_furniture() {
4489 assert_eq!(
4490 host_of("https://cluster.example.net/"),
4491 "cluster.example.net"
4492 );
4493 assert_eq!(
4494 host_of("http://cluster.example.net:8000"),
4495 "cluster.example.net"
4496 );
4497 assert_eq!(host_of("cluster.example.net:8000"), "cluster.example.net");
4498 // An IPv6 literal is bracketed and full of colons, which is why the
4499 // port is not simply everything after the first one.
4500 assert_eq!(host_of("http://[2a02:6b8::1]:8000"), "2a02:6b8::1");
4501 assert_eq!(
4502 host_of("http://user:pass@cluster.example.net"),
4503 "cluster.example.net"
4504 );
4505 }
4506
4507 #[test]
4508 fn only_a_heavy_command_asks_where_to_go() {
4509 // A transport pointed at a host it cannot reach: if a light command
4510 // consulted `/hosts`, this would try to and fail rather than answer
4511 // instantly with the configured address.
4512 let transport = Transport::new(
4513 "http://cluster.invalid:8000",
4514 None,
4515 Duration::from_millis(50),
4516 );
4517
4518 for light in [
4519 Repeatable::Freely,
4520 Repeatable::WithMutationId,
4521 Repeatable::Never,
4522 ] {
4523 let destination = transport.base_for(light);
4524 assert!(
4525 matches!(destination, Destination::Configured(_)),
4526 "{light:?} went looking for a heavy proxy"
4527 );
4528 assert_eq!(destination.address(), "http://cluster.invalid:8000");
4529 }
4530 }
4531
4532 /// A pool of exactly these hosts, seeded as if `/hosts` had just answered.
4533 fn pooled(transport: &Transport, hosts: &[&str]) {
4534 *lock(&transport.heavy) = HeavyProxy::Pool(HeavyPool {
4535 hosts: hosts.iter().map(|host| (*host).to_owned()).collect(),
4536 fetched: Instant::now(),
4537 });
4538 }
4539
4540 /// The destination a failed command reports having been routed to.
4541 fn discovered(base: &str) -> Destination<'static> {
4542 Destination::Discovered(base.to_owned())
4543 }
4544
4545 /// The hosts a seeded pool still holds, or `None` once it stopped being
4546 /// a pool at all.
4547 fn pool_of(transport: &Transport) -> Option<Vec<String>> {
4548 match &*lock(&transport.heavy) {
4549 HeavyProxy::Pool(pool) => Some(pool.hosts.clone()),
4550 _ => None,
4551 }
4552 }
4553
4554 #[test]
4555 fn a_rejected_certificate_drops_the_host_and_a_wrong_command_does_not() {
4556 // The regression #40 is about, in the one place it can be pinned
4557 // without a TLS listener presenting a bad certificate. A cert rejected
4558 // `NotValidForName` is a per-host verdict — the cluster's other
4559 // proxies present names that match — but it is deliberately not
4560 // retriable and not `worth_asking_again`, so a drop gated on either
4561 // predicate (as the first routing release gated it) leaves the client
4562 // pinned to the one bad host: not stepped past, not re-resolved,
4563 // failing every heavy command until the window elapses and the same
4564 // ordered-first host comes back. Dropping must not need the lookup's
4565 // predicate to agree.
4566 let mut transport =
4567 Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
4568 transport.set_proxy_discovery(true);
4569 pooled(
4570 &transport,
4571 &[
4572 "https://n0132-sas.example.net",
4573 "https://n0133-sas.example.net",
4574 ],
4575 );
4576
4577 let rejected: Result<()> = Err(ClientError::Transport {
4578 command: "write_table".to_owned(),
4579 source: Box::new(ureq::Error::Io(std::io::Error::new(
4580 std::io::ErrorKind::InvalidData,
4581 "invalid peer certificate: certificate not valid for name \
4582 \"n0132-sas.example.net\"; certificate is only valid for [\"cluster.example.net\"]",
4583 ))),
4584 });
4585 let reported = transport.after_heavy(
4586 Repeatable::Heavy,
4587 &discovered("https://n0132-sas.example.net"),
4588 rejected,
4589 );
4590
4591 assert!(reported.is_err());
4592 assert_eq!(
4593 pool_of(&transport).as_deref(),
4594 Some(&["https://n0133-sas.example.net".to_owned()][..]),
4595 "the host whose certificate was rejected stayed in the pool"
4596 );
4597
4598 // The other half of the predicate: a failure about the *request* —
4599 // a table that does not exist — will be exactly as wrong next door,
4600 // so it costs the pool nothing.
4601 let wrong_command: Result<()> = Err(ClientError::Http {
4602 command: "write_table".to_owned(),
4603 status: 404,
4604 body: String::new(),
4605 });
4606 let reported = transport.after_heavy(
4607 Repeatable::Heavy,
4608 &discovered("https://n0133-sas.example.net"),
4609 wrong_command,
4610 );
4611
4612 assert!(reported.is_err());
4613 assert_eq!(
4614 pool_of(&transport).as_deref(),
4615 Some(&["https://n0133-sas.example.net".to_owned()][..]),
4616 "a mistaken command evicted a perfectly good host"
4617 );
4618 }
4619
4620 #[test]
4621 fn a_response_too_large_leaves_the_host_that_served_it_in_the_pool() {
4622 // The predicate assertions in `the_cap_counts_…` say this one layer
4623 // up; this is the consequence they stand for, watched happening. A
4624 // pool of two, a `read_file` past the cap, and the question of whose
4625 // fault it was.
4626 let mut transport =
4627 Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
4628 transport.set_proxy_discovery(true);
4629 let both = [
4630 "https://n0132-sas.example.net".to_owned(),
4631 "https://n0133-sas.example.net".to_owned(),
4632 ];
4633 let seed = |transport: &Transport| {
4634 pooled(
4635 transport,
4636 &[
4637 "https://n0132-sas.example.net",
4638 "https://n0133-sas.example.net",
4639 ],
4640 );
4641 };
4642
4643 // What this error was before it was classified: `ureq`'s
4644 // `BodyExceedsLimit` inside a `Transport`. It is not an `Io` error, so
4645 // `rejected_the_certificate` cannot narrow it, and the predicate says
4646 // yes to a host that did nothing wrong.
4647 seed(&transport);
4648 let as_it_was: Result<()> = Err(ClientError::Transport {
4649 command: "read_file".to_owned(),
4650 source: Box::new(ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT)),
4651 });
4652 let _ = transport.after_heavy(
4653 Repeatable::Heavy,
4654 &discovered("https://n0132-sas.example.net"),
4655 as_it_was,
4656 );
4657 assert_eq!(
4658 pool_of(&transport).as_deref(),
4659 Some(&["https://n0133-sas.example.net".to_owned()][..]),
4660 "the old shape was supposed to evict the host — if it no longer \
4661 does, the half of this test that follows has stopped proving \
4662 anything"
4663 );
4664
4665 // And what it is now. The host served the request perfectly, and the
4666 // response will be exactly as large at the next proxy along.
4667 seed(&transport);
4668 let now: Result<()> = Err(body_failure(
4669 "read_file",
4670 RESPONSE_LIMIT,
4671 ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
4672 ));
4673 let reported = transport.after_heavy(
4674 Repeatable::Heavy,
4675 &discovered("https://n0132-sas.example.net"),
4676 now,
4677 );
4678
4679 assert!(reported.is_err());
4680 assert_eq!(
4681 pool_of(&transport).as_deref(),
4682 Some(&both[..]),
4683 "a response too large to hold cost the pool a healthy data proxy"
4684 );
4685 }
4686
4687 #[test]
4688 fn a_response_too_large_keeps_the_way_past_it() {
4689 // `routed_to` names the proxy a routed command actually went to, by
4690 // appending " at <host>" to the command. `ResponseTooLarge` is the one
4691 // command-carrying error it leaves alone, and the coupling is easy to
4692 // miss: the message offers the streaming half of the same command, and
4693 // `error::streaming_advice` finds that half by matching the command
4694 // name exactly — so decorating the name deletes the advice.
4695 //
4696 // Adding a `ResponseTooLarge` arm beside the others is what fails
4697 // here, which is the point: an edit that decorates it uniformly should
4698 // have to read this, rather than have a caller discover it after being
4699 // told a file was too large and not told what to do instead.
4700 let reported = routed_to(
4701 body_failure(
4702 "read_file",
4703 RESPONSE_LIMIT,
4704 ureq::Error::BodyExceedsLimit(0),
4705 ),
4706 "https://n0132-sas.example.net",
4707 );
4708
4709 let message = reported.to_string();
4710 assert!(message.contains("read_file_streaming"), "{message}");
4711 assert!(!message.contains(" at n0132-sas"), "{message}");
4712
4713 // The neighbours it sits between are still decorated, so this is a
4714 // deliberate exception rather than a `routed_to` that stopped working.
4715 let neighbour = routed_to(
4716 ClientError::Decode {
4717 command: "read_file".to_owned(),
4718 reason: "cut short".to_owned(),
4719 },
4720 "https://n0132-sas.example.net",
4721 );
4722 assert!(
4723 neighbour.to_string().contains(" at n0132-sas"),
4724 "{neighbour}"
4725 );
4726 }
4727
4728 #[test]
4729 fn a_pool_with_nobody_left_falls_back() {
4730 // The last host dropped is not a pool of zero to divide by — it is
4731 // the fallback state. That the fallback *ends* — the next heavy
4732 // command after the window asks the cluster again — is pinned where a
4733 // listener can watch it happen:
4734 // `an_emptied_pool_asks_the_cluster_again_after_the_window` in
4735 // tests/request_shape.rs.
4736 let mut transport =
4737 Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
4738 transport.set_proxy_discovery(true);
4739 pooled(&transport, &["https://n0132-sas.example.net"]);
4740
4741 let refused: Result<()> = Err(ClientError::Http {
4742 command: "write_table".to_owned(),
4743 status: 503,
4744 body: String::new(),
4745 });
4746 let _ = transport.after_heavy(
4747 Repeatable::Heavy,
4748 &discovered("https://n0132-sas.example.net"),
4749 refused,
4750 );
4751
4752 assert!(
4753 matches!(&*lock(&transport.heavy), HeavyProxy::FellBack { .. }),
4754 "an emptied pool did not fall back"
4755 );
4756 }
4757
4758 #[test]
4759 fn a_discovered_host_that_spells_the_configured_address_is_still_dropped() {
4760 // `/hosts` may name the configured host itself — a caller pointed
4761 // straight at a data proxy the coordinator also lists — and
4762 // `heavy_base` then builds a base URL byte-identical to the
4763 // configured one. Judging "was this command routed?" by comparing
4764 // addresses reads that failure as the caller's own choice: the
4765 // draining host stays in the pool, is picked again and again for as
4766 // long as it drains, and the error grows a sentence about routing
4767 // being off that is simply false. Which is why `Destination` carries
4768 // the fact instead of the address being trusted to imply it.
4769 let mut transport = Transport::new(
4770 "https://n0132-sas.example.net",
4771 None,
4772 Duration::from_secs(1),
4773 );
4774 transport.set_proxy_discovery(true);
4775 pooled(
4776 &transport,
4777 &[
4778 "https://n0132-sas.example.net",
4779 "https://n0133-sas.example.net",
4780 ],
4781 );
4782
4783 let drained: Result<()> = Err(ClientError::Http {
4784 command: "write_table".to_owned(),
4785 status: 503,
4786 body: String::new(),
4787 });
4788 let reported = transport.after_heavy(
4789 Repeatable::Heavy,
4790 &discovered("https://n0132-sas.example.net"),
4791 drained,
4792 );
4793
4794 assert!(
4795 reported
4796 .expect_err("a 503 is a failure")
4797 .to_string()
4798 .starts_with("write_table at n0132-sas.example.net:"),
4799 "a routed failure at the configured host's own name went unattributed"
4800 );
4801 assert_eq!(
4802 pool_of(&transport).as_deref(),
4803 Some(&["https://n0133-sas.example.net".to_owned()][..]),
4804 "the host was spared the drop for spelling the configured address"
4805 );
4806 }
4807
4808 #[test]
4809 fn starting_an_operation_still_joins_the_transaction() {
4810 // The exception that makes the list a list rather than "anything to do
4811 // with operations": an operation can run inside a transaction, and that
4812 // is what keeps its output invisible until the launcher commits.
4813 let params = map([("operation_type", string("map"))]);
4814 assert!(
4815 transport(Some("3-5d231-10001-db88"))
4816 .in_transaction("start_operation", ¶ms)
4817 .is_some()
4818 );
4819 }
4820
4821 #[test]
4822 fn ureq_follows_no_redirect_for_any_transport() {
4823 // Not "this client refuses redirects" — it follows same-origin ones.
4824 // It is that the answer depends on the credentials, the origin and the
4825 // body all at once, which no `ureq` setting combines, so the 3xx has to
4826 // come back unfollowed for `Transport::redirect` to read.
4827 assert_eq!(authenticated().agent.config().max_redirects(), 0);
4828 assert_eq!(transport(None).agent.config().max_redirects(), 0);
4829 }
4830
4831 #[test]
4832 fn changing_the_timeout_keeps_the_redirect_policy() {
4833 // `set_timeout` rebuilds the agent, which makes it the one place the
4834 // policy can be lost — to a caller doing nothing more suspicious than
4835 // `Client::with_timeout`.
4836 let mut transport = authenticated();
4837 transport.set_timeout(Duration::from_secs(30));
4838
4839 assert_eq!(transport.agent.config().max_redirects(), 0);
4840 assert_eq!(
4841 transport.agent.config().timeouts().global,
4842 Some(Duration::from_secs(30))
4843 );
4844 }
4845
4846 #[test]
4847 fn a_location_is_resolved_against_the_address_it_came_from() {
4848 let request = "http://proxy.example.net:8000/api/v4/exists?path=//tmp";
4849
4850 // Absolute: taken as it stands.
4851 assert_eq!(
4852 resolve(request, "https://data.example.net/api/v4/read_table").as_deref(),
4853 Some("https://data.example.net/api/v4/read_table")
4854 );
4855 // Network-path reference: the scheme survives, the host does not.
4856 assert_eq!(
4857 resolve(request, "//data.example.net/api/v4").as_deref(),
4858 Some("http://data.example.net/api/v4")
4859 );
4860 // Absolute path: the balancer's canonical form of the same request.
4861 assert_eq!(
4862 resolve(request, "/api/v4/exists?path=//tmp").as_deref(),
4863 Some("http://proxy.example.net:8000/api/v4/exists?path=//tmp")
4864 );
4865 // Relative path: against the directory, and the old query goes.
4866 assert_eq!(
4867 resolve(request, "read_table").as_deref(),
4868 Some("http://proxy.example.net:8000/api/v4/read_table")
4869 );
4870 // A reference with no path of its own keeps the request's — RFC 3986
4871 // §5.3. Dropping it back to the directory turns a rewritten command
4872 // into a `404` on `/api/v4/`.
4873 assert_eq!(
4874 resolve(request, "?path=//other").as_deref(),
4875 Some("http://proxy.example.net:8000/api/v4/exists?path=//other")
4876 );
4877 // A bare fragment keeps the query too.
4878 assert_eq!(
4879 resolve(request, "#frag").as_deref(),
4880 Some("http://proxy.example.net:8000/api/v4/exists?path=//tmp#frag")
4881 );
4882 // The base's own fragment is never part of what is resolved against.
4883 assert_eq!(
4884 resolve("http://h/api/v4/exists?path=//tmp#old", "?path=//other").as_deref(),
4885 Some("http://h/api/v4/exists?path=//other")
4886 );
4887 // Nothing to be relative to but the root.
4888 assert_eq!(
4889 resolve("http://h", "?path=//tmp").as_deref(),
4890 Some("http://h?path=//tmp")
4891 );
4892 assert_eq!(
4893 resolve("http://h", "read_table").as_deref(),
4894 Some("http://h/read_table")
4895 );
4896 // Whitespace is header padding, not part of the address.
4897 assert_eq!(
4898 resolve(request, " /hosts ").as_deref(),
4899 Some("http://proxy.example.net:8000/hosts")
4900 );
4901 // Nothing to place.
4902 assert_eq!(resolve(request, ""), None);
4903 assert_eq!(resolve("proxy.example.net", "/hosts"), None);
4904 }
4905
4906 #[test]
4907 fn a_scheme_is_told_from_a_path() {
4908 assert!(has_scheme("https://h/x"));
4909 assert!(has_scheme("HTTP://h/x"));
4910 // A colon inside a path is not a scheme, and neither is one after it.
4911 assert!(!has_scheme("/api/v4/read:table"));
4912 assert!(!has_scheme("//h/x"));
4913 assert!(!has_scheme("read_table"));
4914 assert!(!has_scheme("://h"));
4915 // A scheme cannot start with a digit.
4916 assert!(!has_scheme("8000:80"));
4917 }
4918
4919 #[test]
4920 fn an_origin_is_scheme_host_and_port() {
4921 assert!(same_origin(
4922 "http://proxy.example.net/api/v4/exists",
4923 "http://proxy.example.net/api/v4/read_table?path=//tmp"
4924 ));
4925 // A default port is the port.
4926 assert!(same_origin("https://h/x", "https://h:443/x"));
4927 assert!(same_origin(
4928 "http://H.example.net/x",
4929 "http://h.example.net/x"
4930 ));
4931 // Everything an origin is made of, one at a time.
4932 assert!(!same_origin("http://h/x", "https://h/x"));
4933 assert!(!same_origin("http://h/x", "http://other/x"));
4934 assert!(!same_origin("http://h/x", "http://h:8000/x"));
4935 // The one that reads as `real.example.net` and connects to the other.
4936 assert!(!same_origin(
4937 "http://real.example.net/x",
4938 "http://real.example.net@evil.example.net/x"
4939 ));
4940 // Fails closed rather than calling two unparseable things equal.
4941 assert!(!same_origin("not a url", "not a url"));
4942 assert!(!same_origin("ftp://h/x", "ftp://h/x"));
4943 }
4944
4945 #[test]
4946 fn the_heavy_commands_are_the_ones_that_carry_a_stream() {
4947 // The advice a refused redirect ends with is "go to a heavy proxy",
4948 // which only a heavy command can act on.
4949 //
4950 // Every command this crate itself sends heavily is here. `get_job_stderr`
4951 // was the one that was not, and it is the one a launcher reaches for
4952 // while it is already diagnosing a failure — the worst moment to be
4953 // handed a refusal with no advice in it. See [`HEAVY`]:
4954 // `Repeatable::Heavy` writes the same fact down a second time, and the
4955 // two have to agree.
4956 for command in [
4957 "read_table",
4958 "write_table",
4959 "read_file",
4960 "write_file",
4961 "get_job_input",
4962 "get_job_stderr",
4963 ] {
4964 assert!(HEAVY.contains(&command), "{command}");
4965 }
4966 // And the one reachable only through the raw door, which is the point
4967 // of listing what the cluster calls heavy rather than what this crate
4968 // models.
4969 assert!(HEAVY.contains(&"read_blob_table"));
4970 for command in ["create", "exists", "start_operation", "get_job", "hosts"] {
4971 assert!(!HEAVY.contains(&command), "{command}");
4972 }
4973 }
4974
4975 /// Serves one response carrying `payload`, and hands back the address to
4976 /// send to.
4977 ///
4978 /// `gzip` chooses whether the bytes go out compressed, which is the whole
4979 /// question these tests are about: compressed, the wire and the `Vec` are
4980 /// different quantities, and it matters which one the cap counts.
4981 ///
4982 /// The listener is on its own thread and is dropped with it; nothing here
4983 /// retries, so one accepted connection is the whole of its life.
4984 fn serving(payload: &[u8], gzip: bool) -> String {
4985 use std::io::Write;
4986
4987 let (encoding, body) = if gzip {
4988 use flate2::{Compression, write::GzEncoder};
4989 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
4990 encoder.write_all(payload).expect("compresses");
4991 (
4992 "Content-Encoding: gzip\r\n",
4993 encoder.finish().expect("finishes"),
4994 )
4995 } else {
4996 ("", payload.to_vec())
4997 };
4998
4999 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
5000 let address = listener.local_addr().expect("has an address");
5001
5002 std::thread::spawn(move || {
5003 let (mut stream, _) = listener.accept().expect("accepts");
5004 let mut reader = std::io::BufReader::new(stream.try_clone().expect("clones"));
5005 drain_request(&mut reader);
5006
5007 let mut reply = format!(
5008 "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\
5009 {encoding}Content-Length: {}\r\n\r\n",
5010 body.len()
5011 )
5012 .into_bytes();
5013 reply.extend_from_slice(&body);
5014 stream.write_all(&reply).ok();
5015 stream.flush().ok();
5016 });
5017
5018 format!("http://{address}")
5019 }
5020
5021 /// Serves a gzip body that never ends and decodes to nothing.
5022 ///
5023 /// The case the wire backstop is the only guard against, and the reason
5024 /// [`wire_budget`] is not simply dropped now that the cap counts decoded
5025 /// bytes. An empty deflate *stored* block is five bytes — `00 00 00 ff ff`
5026 /// — and a stream of them makes `flate2` loop **inside a single `read`**,
5027 /// consuming input and producing no output: [`CapReader`] is never
5028 /// re-entered, so `left` never moves and a cap on decoded bytes is never
5029 /// spent.
5030 ///
5031 /// Chunked, so there is no length to disagree with, and the thread writes
5032 /// until the client stops listening.
5033 fn serving_endless_empty_deflate() -> String {
5034 use std::io::Write;
5035
5036 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
5037 let address = listener.local_addr().expect("has an address");
5038
5039 std::thread::spawn(move || {
5040 let (mut stream, _) = listener.accept().expect("accepts");
5041 let mut reader = std::io::BufReader::new(stream.try_clone().expect("clones"));
5042 drain_request(&mut reader);
5043
5044 if stream
5045 .write_all(
5046 b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\
5047 Content-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n",
5048 )
5049 .is_err()
5050 {
5051 return;
5052 }
5053
5054 // A well-formed gzip header, and then a member that is all framing
5055 // and no content, for as long as anyone is reading.
5056 let mut empty_blocks = Vec::new();
5057 for _ in 0..256 {
5058 empty_blocks.extend_from_slice(&[0x00, 0x00, 0x00, 0xff, 0xff]);
5059 }
5060 let mut payload = vec![0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff];
5061 payload.extend_from_slice(&empty_blocks);
5062
5063 loop {
5064 let framed = format!("{:x}\r\n", payload.len());
5065 if stream.write_all(framed.as_bytes()).is_err()
5066 || stream.write_all(&payload).is_err()
5067 || stream.write_all(b"\r\n").is_err()
5068 || stream.flush().is_err()
5069 {
5070 return;
5071 }
5072 payload.clone_from(&empty_blocks);
5073 }
5074 });
5075
5076 format!("http://{address}")
5077 }
5078
5079 /// Reads one whole request off `reader` — the head, and the body if it has
5080 /// one.
5081 ///
5082 /// Not a parser, just enough of one to know when a request has ended.
5083 /// `upload` is why the body half exists: its request *is* a stream, `ureq`
5084 /// sends it chunked, and a listener that answered before reading it would
5085 /// leave the client writing into a socket nobody is draining.
5086 fn drain_request(reader: &mut impl std::io::BufRead) {
5087 let mut head = String::new();
5088 loop {
5089 let mut line = String::new();
5090 match reader.read_line(&mut line) {
5091 Ok(0) | Err(_) => return,
5092 Ok(_) if line == "\r\n" => break,
5093 Ok(_) => head.push_str(&line),
5094 }
5095 }
5096
5097 let header = |name: &str| {
5098 head.lines().find_map(|line| {
5099 let (key, value) = line.split_once(':')?;
5100 key.eq_ignore_ascii_case(name)
5101 .then(|| value.trim().to_owned())
5102 })
5103 };
5104
5105 if header("transfer-encoding").is_some_and(|value| value.eq_ignore_ascii_case("chunked")) {
5106 // `<hex length>\r\n`, the bytes, `\r\n`; a length of zero ends it.
5107 loop {
5108 let mut line = String::new();
5109 if reader.read_line(&mut line).unwrap_or(0) == 0 {
5110 return;
5111 }
5112 let Ok(size) = usize::from_str_radix(line.trim(), 16) else {
5113 return;
5114 };
5115 let mut chunk = vec![0; size + 2];
5116 if reader.read_exact(&mut chunk).is_err() || size == 0 {
5117 return;
5118 }
5119 }
5120 }
5121
5122 if let Some(length) = header("content-length").and_then(|value| value.parse().ok()) {
5123 let mut body = vec![0_u8; length];
5124 let _ = reader.read_exact(&mut body);
5125 }
5126 }
5127
5128 /// `n` bytes gzip cannot shrink, the same `n` bytes every run.
5129 ///
5130 /// A xorshift rather than a constant, and that is the whole point:
5131 /// `vec![7; 4096]` compresses to nothing, so a boundary test written on it
5132 /// cannot see the case where the *wire* is larger than the `Vec` — which is
5133 /// the case the wire backstop has to make room for.
5134 fn incompressible(n: usize) -> Vec<u8> {
5135 let mut state = 0x2545_f491_4f6c_dd1d_u64;
5136 (0..n)
5137 .map(|_| {
5138 state ^= state << 13;
5139 state ^= state >> 7;
5140 state ^= state << 17;
5141 (state >> 33) as u8
5142 })
5143 .collect()
5144 }
5145
5146 /// A transport that will hold `limit` decoded bytes and no more.
5147 fn capped(base: &str, limit: u64) -> Transport {
5148 let mut transport = Transport::new(base, None, Duration::from_secs(10));
5149 transport.set_response_limit(limit);
5150 transport
5151 }
5152
5153 /// A `read_file` through the real `send`, capped at `limit` decoded bytes.
5154 fn read_file_capped(base: &str, limit: u64) -> Result<Vec<u8>> {
5155 capped(base, limit).send(
5156 base,
5157 Method::Get,
5158 "read_file",
5159 &map([("path", string("//tmp/f"))]),
5160 &Payload::None,
5161 )
5162 }
5163
5164 /// A `write_table` through the real `upload`, capped the same way.
5165 fn upload_capped(base: &str, limit: u64) -> Result<Vec<u8>> {
5166 capped(base, limit).upload(
5167 Method::Put,
5168 "write_table",
5169 &map([("path", string("//tmp/t"))]),
5170 &mut std::io::empty(),
5171 )
5172 }
5173
5174 #[test]
5175 fn the_cap_counts_the_bytes_held_and_not_the_bytes_transferred() {
5176 // The claim the documentation makes, and the one it could not keep
5177 // while `ureq`'s own `limit()` was the whole of the guard: that sits
5178 // *under* the gzip decoder, so it bounds the wire and not the `Vec`.
5179 // This body is 40 000 bytes of zeros — comfortably past a 4 096-byte
5180 // cap, and small enough compressed that a cap on the wire would never
5181 // notice it. Measured on a cluster the same way: a 5 000 000-byte file
5182 // of zeros arrives in 4 892 bytes, and `ureq` asked to stop at 100 000
5183 // hands back all five million.
5184 let error = read_file_capped(&serving(&vec![0_u8; 40_000], true), 4_096)
5185 .expect_err("the cap is reached");
5186
5187 assert!(
5188 matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
5189 "{error:?}"
5190 );
5191
5192 // Two mutations this fails. Reverting the call site to an inline
5193 // `Transport { .. }` — the shape that was here — is the first, and it
5194 // is not only a worse message: `BodyExceedsLimit` is not an `Io`
5195 // error, so all three predicates that narrow a `Transport` by looking
5196 // inside it wave it through. The read would be *retried*, and a heavy
5197 // one would drop the host from the pool for serving the request
5198 // perfectly. Enough of those empty the pool and the fallback window
5199 // answers unrelated writes with the control proxy's refusal, which is
5200 // #30 arriving from a caller who only asked for a large file.
5201 assert!(!crate::retry::is_retriable(&error), "{error}");
5202 assert!(!crate::retry::worth_asking_again(&error), "{error}");
5203 assert!(!crate::retry::attributable_to_the_host(&error), "{error}");
5204
5205 // And it says both things the caller needs: how big is too big, and
5206 // what to call instead. `transport error: the response body is larger
5207 // than request limit: 536870912` said neither.
5208 let message = error.to_string();
5209 assert!(message.contains("4096"), "{message}");
5210 assert!(message.contains("read_file_streaming"), "{message}");
5211 }
5212
5213 #[test]
5214 fn a_body_of_exactly_the_cap_is_not_over_it() {
5215 // `ureq`'s `LimitReader` errors on the next `read` once its budget
5216 // reaches zero, and `read_to_end` always makes that read to find the
5217 // end — so the cap it enforces is one byte tighter than the error it
5218 // raises says. A body of exactly the limit is not larger than it.
5219 //
5220 // This is `CapReader`'s half of the boundary and only its half: the
5221 // payload compresses to nothing, so the wire guard is nowhere near
5222 // deciding anything, and tightening `read` to `read as u64 >=
5223 // self.left` is what fails here. The wire guard's own half — a body
5224 // *larger* on the wire than in the `Vec` — is
5225 // `the_wire_backstop_leaves_room_for_a_body_it_must_not_refuse`, two
5226 // tests rather than one so that deleting either cannot quietly unpin
5227 // both boundaries at once.
5228 let held = read_file_capped(&serving(&vec![7_u8; 4_096], true), 4_096)
5229 .unwrap_or_else(|e| panic!("fits exactly, but {e}"));
5230 assert_eq!(held, vec![7_u8; 4_096]);
5231
5232 // And one byte more does not — either encoding, because either guard
5233 // may be the one that notices.
5234 for gzip in [true, false] {
5235 let error = read_file_capped(&serving(&vec![7_u8; 4_097], gzip), 4_096)
5236 .expect_err("one byte past the cap");
5237 assert!(
5238 matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
5239 "gzip={gzip}: {error:?}"
5240 );
5241 }
5242 }
5243
5244 #[test]
5245 fn the_wire_backstop_leaves_room_for_a_body_it_must_not_refuse() {
5246 // The cap counts decoded bytes, so the backstop beneath the decoder
5247 // has to admit whatever the largest permitted body weighs
5248 // *compressed* — and compressed is not always smaller. Deflate expands
5249 // what it cannot shrink, so a body of exactly the cap can cross the
5250 // wire larger than the cap. Measured here with `flate2`: 4 096
5251 // incompressible bytes gzip to 4 119, which a budget of `limit + 1`
5252 // refuses — a response inside the documented ceiling turned away by
5253 // the guard for responses outside it. See `wire_budget`.
5254 let awkward = incompressible(4_096);
5255 let compressed = {
5256 use std::io::Write;
5257 let mut encoder =
5258 flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
5259 encoder.write_all(&awkward).expect("compresses");
5260 encoder.finish().expect("finishes").len()
5261 };
5262 assert!(
5263 compressed > 4_096,
5264 "this needs a body gzip makes bigger, and {compressed} is not one"
5265 );
5266
5267 let held = read_file_capped(&serving(&awkward, true), 4_096)
5268 .unwrap_or_else(|e| panic!("{compressed} wire bytes for 4096 held, and {e}"));
5269 assert_eq!(held, awkward);
5270
5271 // And the plainer case the slack was first there for: with no encoding
5272 // the two guards count the same bytes, and `ureq`'s errors on the read
5273 // that finds the end. A budget of `limit` fails both halves of this.
5274 let held = read_file_capped(&serving(&vec![7_u8; 4_096], false), 4_096)
5275 .unwrap_or_else(|e| panic!("uncompressed and exactly the cap, but {e}"));
5276 assert_eq!(held, vec![7_u8; 4_096]);
5277 }
5278
5279 #[test]
5280 fn an_endless_body_that_decodes_to_nothing_is_still_bounded() {
5281 // Why the wire backstop stays now that the cap counts decoded bytes. A
5282 // chunked stream of empty deflate stored blocks decodes to nothing at
5283 // all, so `CapReader` never spends a byte of its budget — and `flate2`
5284 // loops *inside* one `read`, so it is not even re-entered to notice.
5285 // Only the limit under the decoder ends this.
5286 //
5287 // On its own thread with a deadline, because what this pins is not a
5288 // wrong answer but no answer: without the backstop the read does not
5289 // return, and a test that hangs is a test that says nothing.
5290 let (done, answer) = std::sync::mpsc::channel();
5291 let base = serving_endless_empty_deflate();
5292 std::thread::spawn(move || {
5293 let _ = done.send(read_file_capped(&base, 4_096));
5294 });
5295
5296 let outcome = answer
5297 .recv_timeout(Duration::from_secs(20))
5298 .expect("a body that never ends must still be refused, and was not");
5299 let error = outcome.expect_err("nothing decoded, so there is nothing to hand back");
5300 assert!(
5301 matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
5302 "{error:?}"
5303 );
5304 }
5305
5306 #[test]
5307 fn the_cap_a_transport_is_built_with_is_the_documented_one() {
5308 // `set_response_limit` is what lets every test around this one cost
5309 // 4 KiB instead of half a gigabyte — and it is also what would let the
5310 // cap quietly become something else, because a default of `u64::MAX`
5311 // disables the guard crate-wide and leaves all of them passing. This
5312 // is the one assertion that reads the number itself.
5313 let transport = Transport::new("https://cluster.example.net", None, Duration::from_secs(1));
5314 assert_eq!(transport.response_limit, RESPONSE_LIMIT);
5315 assert_eq!(RESPONSE_LIMIT, 512 * 1024 * 1024);
5316 }
5317
5318 #[test]
5319 fn a_response_this_client_will_not_hold_fails_the_upload_that_got_it() {
5320 // An upload reads its answer whatever the caller wants with it — a
5321 // body left unread keeps the connection out of the pool — and every
5322 // other way that read can fail is swallowed on purpose: the status
5323 // line already said the write was done, a heavy command is sent once,
5324 // and failing there would fail a write that succeeded.
5325 //
5326 // This one is not that. `raw_command_upload` hands the `Vec` back as
5327 // *the answer*, so an answer too large to hold, swallowed, becomes a
5328 // command that returned nothing — the silent corruption the cap exists
5329 // to turn into a refusal. Making the arm `=> Vec::new()` is what fails
5330 // here, and nothing else in the suite notices it.
5331 let error = upload_capped(&serving(&vec![0_u8; 40_000], true), 4_096)
5332 .expect_err("the answer is past the cap");
5333
5334 assert!(
5335 matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
5336 "{error:?}"
5337 );
5338
5339 // And an answer that fits is still handed back, so the refusal above
5340 // is about the size of it and not about uploads.
5341 let body = upload_capped(&serving(b"{\"value\"={}}", true), 4_096).expect("fits");
5342 assert_eq!(body, b"{\"value\"={}}");
5343 }
5344
5345 #[test]
5346 fn a_body_over_the_cap_blames_the_request_and_not_the_proxy_that_served_it() {
5347 // The message, per command, without a socket. Each buffered read
5348 // points at its own streaming half, and a command that has none
5349 // promises nothing.
5350 let file = body_failure(
5351 "read_file",
5352 RESPONSE_LIMIT,
5353 ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
5354 )
5355 .to_string();
5356 assert!(file.contains("536870912"), "{file}");
5357 assert!(file.contains("read_file_streaming"), "{file}");
5358
5359 let table = body_failure(
5360 "read_table",
5361 RESPONSE_LIMIT,
5362 ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
5363 )
5364 .to_string();
5365 assert!(table.contains("read_table_streaming"), "{table}");
5366
5367 let get = body_failure(
5368 "get",
5369 RESPONSE_LIMIT,
5370 ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT),
5371 )
5372 .to_string();
5373 assert!(!get.contains("streaming"), "{get}");
5374
5375 // The cap the caller is told is the one they can plan around, not the
5376 // one `ureq` was handed — `read_capped` gives it a byte more so that a
5377 // body of exactly the cap survives the wire guard too.
5378 let quoted = body_failure(
5379 "read_file",
5380 RESPONSE_LIMIT,
5381 ureq::Error::BodyExceedsLimit(RESPONSE_LIMIT + 1),
5382 )
5383 .to_string();
5384 assert!(quoted.contains("536870912"), "{quoted}");
5385 }
5386
5387 #[test]
5388 fn a_body_cut_short_is_still_the_network_failure_it_always_was() {
5389 // The other half, and the reason the split is a `match` rather than a
5390 // blanket reclassification: a connection cut while the body streams in
5391 // is the same failure as one cut a packet earlier, worth waiting for
5392 // and — for a heavy command — worth trying another host for.
5393 let error = body_failure(
5394 "read_file",
5395 RESPONSE_LIMIT,
5396 ureq::Error::Io(std::io::Error::new(
5397 std::io::ErrorKind::ConnectionReset,
5398 "connection reset by peer",
5399 )),
5400 );
5401
5402 assert!(matches!(error, ClientError::Transport { .. }), "{error:?}");
5403 assert!(crate::retry::is_retriable(&error), "{error}");
5404 assert!(crate::retry::attributable_to_the_host(&error), "{error}");
5405 }
5406
5407 #[test]
5408 fn a_deadline_is_shared_out_and_then_refused() {
5409 let command = "exists";
5410 // No deadline: nothing to share out, and nothing to refuse.
5411 assert!(remaining(None, command).expect("no deadline").is_none());
5412
5413 let ahead = Instant::now() + Duration::from_secs(30);
5414 let left = remaining(Some(ahead), command)
5415 .expect("still time")
5416 .expect("a bound");
5417 assert!(left <= Duration::from_secs(30) && left > Duration::from_secs(29));
5418
5419 // Spent. Reported as the timeout it is, and as a `Transport` error, so
5420 // the retry policy treats it exactly as it treats one that happened
5421 // inside a request.
5422 let error = remaining(Some(Instant::now() - Duration::from_millis(1)), command)
5423 .expect_err("the budget is gone");
5424 assert!(matches!(error, ClientError::Transport { .. }), "{error:?}");
5425 assert!(error.to_string().contains("timeout"), "{error}");
5426 assert!(crate::retry::is_retriable(&error), "{error:?}");
5427 }
5428}