Skip to main content

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) = &parameters.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", &params)
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", &params).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", &params)
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, &params).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", &params)
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}