Skip to main content

dynamic_config_s3/
lib.rs

1//! Read [`dynamic-config`] configuration from an S3 object.
2//!
3//! The AWS SDK is async throughout, so this implements the **async**
4//! [`AsyncRemoteSource`] trait rather than the blocking one.
5//!
6//! ```no_run
7//! use dynamic_config_s3::S3;
8//!
9//! # struct DbConfig;
10//! # impl DbConfig {
11//! #     fn set_remote_async(_: S3) {}
12//! #     async fn refresh_remote_async() -> Result<(), dynamic_config::Error> { Ok(()) }
13//! # }
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! // Credentials come from the environment the way every other AWS tool finds
16//! // them: variables, the profile, the instance role, IRSA.
17//! DbConfig::set_remote_async(S3::new("myapp-config", "prod/db.json").await?);
18//!
19//! // Fetching is explicit; the load that follows touches no network.
20//! DbConfig::refresh_remote_async().await?;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! # What it reads
26//!
27//! One object, whose body is **a whole configuration document** — the same
28//! bytes that would be in a config file. The format comes from the key's
29//! extension, or from [`with_format`](S3::with_format).
30//!
31//! # Several objects as one document
32//!
33//! A deployment that splits its configuration across a prefix —
34//! `prod/db.json`, `prod/server.json` — can have one source read the lot, and
35//! [`Keys`] says which:
36//!
37//! ```no_run
38//! # use dynamic_config_s3::{Keys, S3};
39//! # async fn example() -> Result<(), dynamic_config::Error> {
40//! // Named keys: a list of layers, merged in the order given, later wins.
41//! let s3 = S3::new("myapp-config", Keys::several(["prod/base.json", "prod/local.json"])).await?;
42//!
43//! // A prefix: disjoint sections, and an overlap between two of them is an error.
44//! let s3 = S3::new("myapp-config", Keys::prefix("prod/"))
45//!     .await?
46//!     .with_format(dynamic_config::Format::Json);
47//! # Ok(())
48//! # }
49//! ```
50//!
51//! **Neither shape is atomic, and S3 offers nothing that would make one so.**
52//! There is no batch read: a named list is one `GetObject` per key, and a
53//! prefix is one `ListObjectsV2` and then one `GetObject` per key it named. A
54//! write landing between two of those requests can produce a document that
55//! never existed as a whole. AWS made `ListObjectsV2` strongly consistent in
56//! December 2020, so the listing itself is not the hole it once was — but
57//! another implementation of this API is free to be eventually consistent, and
58//! the gap between the listing and the reads is there in every case.
59//!
60//! **The 512-key bound is applied to the listing, not to what it fetched.**
61//! `ListObjectsV2` is paginated, so the count is checked as each page arrives
62//! and a prefix over a bucket of a million objects is refused after one
63//! request rather than after a million bodies. Every key the store answers
64//! with is checked against the literal prefix, and a key ending in `/` — the
65//! zero-byte object the console makes when somebody creates a "folder" — is
66//! not a document and is skipped.
67//!
68//! Three consequences that belong here rather than in an incident:
69//!
70//! - **Provenance becomes store-grained.** The merged document is one layer,
71//!   so `source_of` answers "from s3 … keys a, b" rather than naming which key
72//!   supplied a value. [`describe`](AsyncRemoteSource::describe) names the
73//!   whole set, which is as close as one layer gets.
74//! - **One unreadable key fails the whole fetch.** A configuration quietly
75//!   missing a section is worse than a refresh that failed and left the last
76//!   document serving.
77//! - **A multi-key source cannot be watched.** What a watch delivers is the
78//!   object that changed, and a merged document has no one ETag; it refuses at
79//!   [`watch`](S3::watch) and points at polling `refresh_remote_async()`.
80//!
81//! # Credentials
82//!
83//! Through `aws-config`, which is the chain every AWS tool uses:
84//! `AWS_ACCESS_KEY_ID`, the shared profile, the EC2 instance role, the ECS task
85//! role, and IRSA on EKS. That is deliberately not re-implemented here — a
86//! second credential chain in a program that already has one is a bug waiting
87//! for a rotation.
88//!
89//! [`with_config`](S3::with_config) takes an `SdkConfig` the program already
90//! built, which is also how a non-AWS endpoint is reached: MinIO, Ceph,
91//! Cloudflare R2, Backblaze B2 all speak this API.
92//!
93//! # Watching
94//!
95//! S3 cannot tell you when an object changes without a notification pipeline —
96//! SNS, SQS, EventBridge — that is a deployment's decision, not a library's. So
97//! [`watch`](S3::watch) polls, and says so.
98//!
99//! What it does not do is download the object every tick. `HEAD` returns the
100//! ETag, which changes when the body does, so an unchanged configuration costs
101//! one small request and no transfer.
102//!
103//! **A failing poll says so, if it is asked to.**
104//! [`reporting_to`](S3::reporting_to) hands the loop the same sink it delivers
105//! through, and the failures inside it — a `HEAD` that did not answer, and a
106//! `GET` that did not answer after the ETag moved — are reported to the
107//! `RemoteStatus` as they happen. Surviving a failure is what makes that
108//! necessary: a loop that retries forever is a loop that reports nothing
109//! forever, so `dynamic_config_remote_up` would describe the last *delivery*
110//! rather than the last *attempt*.
111//!
112//! A credential the store will not accept — `AccessDenied`,
113//! `InvalidAccessKeyId`, `SignatureDoesNotMatch`, an expired session token —
114//! is reported as `ErrorKind::Auth` rather than `Remote`, because no amount of
115//! waiting persuades S3 otherwise. A clock too far out of step
116//! (`RequestTimeTooSkewed`) shares the same 403 and stays `Remote`: that one
117//! does come right.
118//!
119//! # Timeouts
120//!
121//! [`S3::with_timeout`] is the deadline for a single fetch attempt, excluding
122//! retries the underlying client performs. Here that exclusion has teeth: the
123//! SDK retries, so **a five-second timeout with three attempts is a
124//! fifteen-second call**. See the README's Timeouts section.
125//!
126//! # This crate needs a tokio runtime
127//!
128//! Not this crate's choice: the AWS SDK it is built on is tokio-based
129//! (`rt-tokio`), and [`watch`](S3::watch) sleeps on tokio's timer. Driving
130//! it from another executor panics inside the SDK. The etcd and NATS
131//! companions are executor-agnostic; this one is honest about not being.
132//!
133//!
134//! # Every failure branch of the watch loop, and what it reports
135//!
136//! A watch is the half of a store `dynamic-config` cannot see, and
137//! [`reporting_to`](S3::reporting_to) is what lets it speak: the sink the
138//! loop already holds is told about every attempt that came back with
139//! nothing. Which attempts those are is a table rather than prose, because
140//! the question an operator asks is *which* silence is deliberate.
141//!
142//! Three rules decide the column, and they are the same three in all seven
143//! store crates:
144//!
145//! 1. **A failure the loop survives by retrying reports.** That is the case
146//!    the whole feature exists for: the stream is down, the last delivery is
147//!    old, and nothing else would ever say so out loud.
148//! 2. **A recovery that worked stays silent.** Only a delivery or a fetch
149//!    clears the streak, so reporting a five-minute token turning over on a
150//!    healthy cluster would drive `remote_up` to zero and leave it there.
151//! 3. **A refusal that never asked the store reports nowhere.** No format, a
152//!    key shape that cannot be watched, material that will not build a
153//!    client: `RemoteStatus::reachable()` is *whether the store answered the
154//!    last time it was asked*, and these never ask. They are returned to the
155//!    caller, who is the one holding the mistake — and a status cannot
156//!    correct them, since it carries a kind and a path and no message.
157//!
158//! | Branch | Reports |
159//! |---|---|
160//! | the format is missing, or the source names several keys | no — rule 3: nothing has been asked of the bucket |
161//! | the `HEAD` that reads the ETag fails — an expired credential, a bucket briefly unreachable | **yes**, and the loop waits out the interval |
162//! | the `GET` after a moved tag fails | **yes**, and `seen` is left where it was so the next tick tries the same tag again |
163//! | the first tick, or an ETag that has not moved | no — the bucket answered |
164//! | `on_change` refuses the document | no — the bucket answered; `apply` counted the delivery, and what the document did next is `ConfigStatus`'s half |
165//!
166//! [`dynamic-config`]: https://docs.rs/dynamic-config
167
168#![forbid(unsafe_code)]
169#![deny(missing_docs)]
170#![cfg_attr(docsrs, feature(doc_cfg))]
171
172use std::future::Future;
173use std::pin::Pin;
174use std::time::Duration;
175
176use aws_sdk_s3::config::timeout::TimeoutConfig;
177use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, RemoteSink, Watching};
178use dynamic_config_store_core::attempts::Attempts;
179use dynamic_config_store_core::documents::{self, Overlap, MOST_KEYS};
180use dynamic_config_store_core::guarded;
181
182/// The AWS types a caller needs to configure this, re-exported so using them
183/// needs no direct dependency on the SDK.
184pub use aws_config::SdkConfig;
185pub use aws_sdk_s3::Client;
186
187use dynamic_config_store_core::tls as tls_core;
188/// A private certificate authority and a client certificate, as data.
189///
190/// The shared vocabulary all seven store crates take, so that reaching TLS
191/// never means naming an SDK type — see [`S3::with_tls`]. S3 is one of two
192/// stores here that cannot express the whole of it: the SDK's TLS context has
193/// a trust store and no client-certificate slot, so mTLS is refused rather
194/// than ignored.
195pub use dynamic_config_store_core::tls::TlsConfig;
196use rustls_pki_types::pem::PemObject;
197
198use aws_sdk_s3::error::ProvideErrorMetadata;
199
200/// The error codes S3 uses for a credential it will not accept.
201///
202/// Matched on the code rather than the 403 that carries them, because a 403
203/// is also what `RequestTimeTooSkewed` arrives as — a clock problem that NTP
204/// does fix, and so not something to stop a watch loop over.
205const AUTH_CODES: [&str; 6] = [
206    "AccessDenied",
207    "InvalidAccessKeyId",
208    "SignatureDoesNotMatch",
209    "ExpiredToken",
210    "InvalidToken",
211    "TokenRefreshRequired",
212];
213
214/// The most `ListObjectsV2` pages one prefix read will ask for.
215///
216/// The key budget already stops a listing that is merely large: one page is
217/// asked for `MOST_KEYS + 1` keys, so a prefix over anything bigger than the
218/// budget is refused on the first or second page. This is the other failure —
219/// a store that keeps answering "truncated" with a continuation token and no
220/// keys, which the budget on the keys cannot see because the count never
221/// moves.
222///
223/// Thirty-two rather than the two a well-behaved store needs: `max-keys` is a
224/// *maximum*, and an implementation of this API is free to answer with fewer
225/// than it was asked for. At sixteen keys a page this still reaches the whole
226/// budget, which puts the cap well clear of a small page size and still well
227/// short of a loop.
228const MOST_LIST_PAGES: usize = 32;
229
230/// What a source reads: one object, several named ones, or a prefix.
231///
232/// Every constructor takes one, and a bare `&str` or `String` is
233/// [`Keys::one`] — so the single-key spelling every caller already wrote keeps
234/// working unchanged.
235#[derive(Clone, Debug, PartialEq, Eq)]
236pub enum Keys {
237    /// One object, whose body is the whole document.
238    One(String),
239    /// Several named objects, merged **in the order given — later wins**.
240    ///
241    /// The rule a list of `.file(..)` calls already teaches: the caller wrote
242    /// the list, so the list is the precedence. One `GetObject` per key,
243    /// because S3 has no batch read — so the set is **not** read atomically.
244    Several(Vec<String>),
245    /// Every object under a literal prefix, merged as **disjoint sections**.
246    ///
247    /// A caller naming a prefix is not expressing an order — S3 lists keys in
248    /// UTF-8 order, which is nobody's precedence — so two objects under it
249    /// supplying the same path is a deployment bug, and reported as one rather
250    /// than resolved. One `ListObjectsV2` (paginated) and then one
251    /// `GetObject` per key.
252    Prefix(String),
253}
254
255impl Keys {
256    /// One object, whose body is the whole document.
257    #[must_use]
258    pub fn one(key: impl Into<String>) -> Self {
259        Self::One(key.into())
260    }
261
262    /// Several named objects, merged in the order given — later wins.
263    #[must_use]
264    pub fn several<I, S>(keys: I) -> Self
265    where
266        I: IntoIterator<Item = S>,
267        S: Into<String>,
268    {
269        Self::Several(keys.into_iter().map(Into::into).collect())
270    }
271
272    /// Every object under `prefix`, merged as disjoint sections.
273    #[must_use]
274    pub fn prefix(prefix: impl Into<String>) -> Self {
275        Self::Prefix(prefix.into())
276    }
277
278    /// The keys as a slice, for the diagnostics and the format inference.
279    ///
280    /// A prefix has none to list — the set is not known until the store
281    /// answers.
282    fn named(&self) -> &[String] {
283        match self {
284            Self::One(key) => std::slice::from_ref(key),
285            Self::Several(keys) => keys,
286            Self::Prefix(_) => &[],
287        }
288    }
289
290    /// How a diagnostic names what this source reads.
291    ///
292    /// One key renders as the key itself, so every message a single-key source
293    /// has ever produced is unchanged.
294    fn describe(&self) -> String {
295        match self {
296            Self::One(key) => key.clone(),
297            Self::Several(keys) => format!("keys {}", keys.join(", ")),
298            Self::Prefix(prefix) => format!("prefix {prefix}"),
299        }
300    }
301}
302
303impl From<&str> for Keys {
304    fn from(key: &str) -> Self {
305        Self::one(key)
306    }
307}
308
309impl From<String> for Keys {
310    fn from(key: String) -> Self {
311        Self::One(key)
312    }
313}
314
315impl From<&String> for Keys {
316    fn from(key: &String) -> Self {
317        Self::one(key)
318    }
319}
320
321/// An object in S3, as a configuration source.
322pub struct S3 {
323    client: Client,
324    bucket: String,
325    keys: Keys,
326    format: Option<Format>,
327    /// Why the keys' own extensions could not settle the format between them.
328    ///
329    /// Kept rather than reported at construction because `from_client` cannot
330    /// fail and because `with_format` is allowed to settle it afterwards.
331    disagreement: Option<String>,
332    /// The endpoint override, when the construction path knew one. Only for
333    /// `describe()`: the endpoint tells MinIO apart from AWS in an error.
334    endpoint: Option<String>,
335    /// Where the watch loop reports a poll that came back with nothing.
336    ///
337    /// Nobody, unless [`reporting_to`](S3::reporting_to) said otherwise: a
338    /// fetch records itself through `refresh_remote_async`, and a poll is
339    /// the half of this store `dynamic-config` cannot see on its own.
340    attempts: Attempts,
341}
342
343impl S3 {
344    /// The object `key` in `bucket`, with credentials from the environment.
345    ///
346    /// `key` is a key — `"prod/db.json"` — or a [`Keys`], for the
347    /// several-objects and prefix forms.
348    ///
349    /// The format is taken from the key's extension — `prod/db.json` is JSON. A
350    /// key without one, and every prefix, needs
351    /// [`with_format`](Self::with_format).
352    ///
353    /// This resolves credentials, which may read a file or call the instance
354    /// metadata service — the one constructor in this family that does I/O,
355    /// because the credential chain is what it is.
356    pub async fn new(bucket: impl Into<String>, key: impl Into<Keys>) -> Result<Self, Error> {
357        let config = aws_config::load_from_env().await;
358
359        Ok(Self::with_config(&config, bucket, key))
360    }
361
362    /// Uses an `SdkConfig` the program already built.
363    ///
364    /// For a caller that already talks to AWS, and for anything that is not
365    /// AWS: MinIO, Ceph, R2 and B2 all speak this API, and all of them need an
366    /// endpoint override the environment cannot express.
367    ///
368    /// ```no_run
369    /// # use dynamic_config_s3::S3;
370    /// # async fn example() {
371    /// let config = aws_config::from_env()
372    ///     .endpoint_url("http://minio.internal:9000")
373    ///     .load()
374    ///     .await;
375    ///
376    /// let s3 = S3::with_config(&config, "myapp-config", "prod/db.json");
377    /// # }
378    /// ```
379    #[must_use]
380    pub fn with_config(
381        config: &SdkConfig,
382        bucket: impl Into<String>,
383        key: impl Into<Keys>,
384    ) -> Self {
385        // `force_path_style` is what makes every S3-compatible server work:
386        // `http://host/bucket/key` rather than `http://bucket.host/key`, which
387        // needs DNS entries only AWS has.
388        let s3 = aws_sdk_s3::config::Builder::from(config)
389            .force_path_style(true)
390            .build();
391
392        let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
393        source.endpoint = config.endpoint_url().map(str::to_owned);
394
395        source
396    }
397
398    /// As [`with_config`](Self::with_config), with a private certificate
399    /// authority.
400    ///
401    /// The same vocabulary as the other six store crates, spelled as *data* —
402    /// nothing here names an SDK or a `rustls` type:
403    ///
404    /// ```no_run
405    /// # use dynamic_config_s3::{S3, TlsConfig};
406    /// # async fn example() -> Result<(), dynamic_config::Error> {
407    /// let config = aws_config::from_env()
408    ///     .endpoint_url("https://minio.internal:9000")
409    ///     .load()
410    ///     .await;
411    ///
412    /// let s3 = S3::with_tls(
413    ///     &config,
414    ///     "myapp-config",
415    ///     "prod/db.json",
416    ///     &TlsConfig::new().with_ca_certificate_file("/etc/ssl/private-ca.pem"),
417    /// )?;
418    /// # Ok(())
419    /// # }
420    /// ```
421    ///
422    /// This is for the S3-compatible servers, which is where a private
423    /// authority actually turns up: MinIO, Ceph and a company's own gateway all
424    /// present certificates AWS' public chain has never heard of.
425    ///
426    /// # What S3 cannot express
427    ///
428    /// **A client certificate.** The SDK reaches TLS through
429    /// `aws-smithy-http-client`, whose `TlsContext` has a trust store and
430    /// nothing else — there is no client-certificate slot to fill, at any
431    /// version this crate can depend on. So
432    /// [`with_client_certificate_files`] and [`with_client_certificate_pem`]
433    /// are **refused here**, naming the call and pointing at
434    /// [`from_client`](Self::from_client) — not ignored, because a caller who
435    /// asked to present a certificate and did not would discover it as an
436    /// authentication failure a long way from the cause.
437    ///
438    /// A caller who needs mTLS to an S3-compatible server builds the connector
439    /// themselves and hands over the finished `Client`. That is what the escape
440    /// hatch is for, and it is untouched.
441    ///
442    /// **The CA replaces the platform trust store** rather than adding to it,
443    /// which is what naming a private authority means. A deployment that needs
444    /// both puts both in the file.
445    ///
446    /// There is no way to turn verification off; [`TlsConfig`]'s own
447    /// documentation argues that one, and the SDK's TLS context offers no such
448    /// switch to forward even if this crate wanted to.
449    ///
450    /// # Errors
451    ///
452    /// If the configuration names a client certificate, if a PEM file cannot be
453    /// read, or if the TLS context will not build.
454    ///
455    /// [`with_client_certificate_files`]: TlsConfig::with_client_certificate_files
456    /// [`with_client_certificate_pem`]: TlsConfig::with_client_certificate_pem
457    pub fn with_tls(
458        config: &SdkConfig,
459        bucket: impl Into<String>,
460        key: impl Into<Keys>,
461        tls: &TlsConfig,
462    ) -> Result<Self, Error> {
463        let bucket = bucket.into();
464        let described = format!("s3 {bucket}");
465
466        if tls.client_certificate().is_some() {
467            return Err(tls_core::unsupported(
468                &described,
469                "a client certificate",
470                "the AWS SDK's TLS context has a trust store and no \
471                 client-certificate slot; build the connector yourself and use \
472                 `from_client`",
473            ));
474        }
475
476        let mut trust_store = aws_smithy_http_client::tls::TrustStore::empty();
477
478        if let Some(pem) = tls.ca_certificate_pem(&described)? {
479            // Parsed here and thrown away, purely to refuse. The SDK's rustls
480            // connector calls `.expect("cert parsable")` on this material, so
481            // a certificate it cannot read is a *panic* at the first
482            // connection — a long way from the call that supplied it, and in a
483            // library whose whole job is to not take a process down. The
484            // parser's own message is dropped for the reason it is dropped
485            // everywhere in this family: it renders the line it choked on.
486            let readable = rustls_pki_types::CertificateDer::pem_slice_iter(&pem)
487                .collect::<Result<Vec<_>, _>>()
488                .map_err(|_| {
489                    Error::remote(format!(
490                        "{described}: the CA certificate is not PEM-encoded \
491                         certificate material"
492                    ))
493                })?;
494
495            if readable.is_empty() {
496                return Err(Error::remote(format!(
497                    "{described}: the CA certificate holds no certificate; it \
498                     is refused rather than ignored"
499                )));
500            }
501
502            trust_store = trust_store.with_pem_certificate(pem);
503        }
504
505        let context = aws_smithy_http_client::tls::TlsContext::builder()
506            .with_trust_store(trust_store)
507            .build()
508            // The SDK renders the PEM parse failure underneath, and a parse
509            // failure renders the line it choked on — so the upstream text is
510            // dropped rather than wrapped, the same rule the whole family
511            // holds to.
512            .map_err(|_| {
513                Error::remote(format!(
514                    "{described}: the CA certificate was refused; check that it \
515                     is PEM-encoded certificate material"
516                ))
517            })?;
518
519        let http = aws_smithy_http_client::Builder::new()
520            .tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
521                aws_smithy_http_client::tls::rustls_provider::CryptoMode::AwsLc,
522            ))
523            .tls_context(context)
524            .build_https();
525
526        // `force_path_style` for the same reason `with_config` sets it: it is
527        // what makes every S3-compatible server work, and those are exactly the
528        // servers a private authority belongs to.
529        let s3 = aws_sdk_s3::config::Builder::from(config)
530            .force_path_style(true)
531            .http_client(http)
532            .build();
533
534        let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
535        source.endpoint = config.endpoint_url().map(str::to_owned);
536
537        Ok(source)
538    }
539
540    /// Uses a client the program already has.
541    ///
542    /// The escape hatch, and it stays one: a connector this crate has no
543    /// spelling for — mTLS, a proxy, a DNS resolver — is built here and handed
544    /// over finished.
545    #[must_use]
546    pub fn from_client(client: Client, bucket: impl Into<String>, key: impl Into<Keys>) -> Self {
547        let keys = key.into();
548
549        let (format, disagreement) = match documents::agreed_format(keys.named()) {
550            Ok(format) => (format, None),
551            Err(complaint) => (None, Some(complaint)),
552        };
553
554        Self {
555            client,
556            bucket: bucket.into(),
557            keys,
558            format,
559            disagreement,
560            endpoint: None,
561            attempts: Attempts::default(),
562        }
563    }
564
565    /// States the format, for a key whose name does not.
566    ///
567    /// Required for [`Keys::Prefix`] — a prefix has no extension — and it also
568    /// settles a list whose keys name two different formats.
569    #[must_use]
570    pub fn with_format(mut self, format: Format) -> Self {
571        self.format = Some(format);
572        // The caller has now said which format wins, so the keys no longer
573        // have to agree between themselves.
574        self.disagreement = None;
575        self
576    }
577
578    /// Reports this source's **watch** failures to `sink`.
579    ///
580    /// A poll loop is the half of a store `dynamic-config` cannot see. A
581    /// delivery keeps `RemoteStatus` current because
582    /// [`RemoteSink::apply`] records one — but a poll that keeps failing
583    /// delivers nothing and would otherwise say nothing: an expired
584    /// credential, a bucket policy that changed under the process, a
585    /// gateway that went away. `dynamic_config_remote_up` would report the
586    /// last *delivery* rather than the last *attempt*, and a bucket that
587    /// stopped answering an hour ago would look healthy until something
588    /// called `refresh_remote_async()`.
589    ///
590    /// ```no_run
591    /// # use dynamic_config_s3::S3;
592    /// # struct DbConfig;
593    /// # impl DbConfig {
594    /// #     fn remote_sink() -> dynamic_config::RemoteSink { unimplemented!() }
595    /// # }
596    /// # async fn example() -> Result<(), dynamic_config::Error> {
597    /// // Taken once, where the loop is wired: a sink captures the generation
598    /// // of the source installed at that moment, which is what stops a loop
599    /// // winding down from charging its failures to its replacement.
600    /// let sink = DbConfig::remote_sink();
601    ///
602    /// let watcher = S3::new("myapp-config", "prod/db.json").await?.reporting_to(sink);
603    /// # Ok(())
604    /// # }
605    /// ```
606    ///
607    /// **A failure moves the failure streak and nothing else.** The fetch
608    /// count and the clock are left alone, so
609    /// `dynamic_config_remote_last_fetch_seconds` keeps ageing while
610    /// `dynamic_config_remote_up` goes to zero — the pair an alert wants.
611    /// Only the failure's kind and key path are recorded; a bucket, an
612    /// endpoint and a key never reach a `RemoteStatus`.
613    ///
614    /// It changes nothing about what [`watch`](Self::watch) *returns*, and
615    /// nothing about [`fetch`](AsyncRemoteSource::fetch), which already
616    /// records itself through `refresh_remote_async()`.
617    ///
618    /// [`RemoteSink::apply`]: dynamic_config::RemoteSink::apply
619    #[must_use]
620    pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
621        self.attempts = Attempts::to(sink);
622        self
623    }
624
625    /// The format, or an error naming the call that supplies one.
626    fn format(&self) -> Result<Format, Error> {
627        if let Some(complaint) = &self.disagreement {
628            return Err(Error::remote(format!("{}: {complaint}", self.describe())));
629        }
630
631        self.format.ok_or_else(|| {
632            Error::remote(format!(
633                "{}: the key names no format; call `with_format`",
634                self.describe()
635            ))
636        })
637    }
638
639    /// The one key this source reads, or an error saying it reads several.
640    ///
641    /// A watch here compares ETags, and an ETag belongs to an object: a set of
642    /// objects has none, and following one member's would fire on that member
643    /// and miss every other.
644    fn single_key(&self) -> Result<&str, Error> {
645        match &self.keys {
646            Keys::One(key) => Ok(key),
647            _ => Err(Error::remote(format!(
648                "{}: a source that reads several keys cannot be watched; \
649                 poll `refresh_remote_async()` on a timer instead",
650                self.describe()
651            ))),
652        }
653    }
654
655    /// What two of this source's keys supplying one path means.
656    ///
657    /// The distinction the feature turns on: a caller who wrote the list wrote
658    /// the precedence with it, and a caller who wrote a prefix wrote no order
659    /// at all — so the first merges and the second refuses.
660    fn overlap(&self) -> Overlap {
661        match self.keys {
662            Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
663            Keys::Prefix(_) => Overlap::Refused,
664        }
665    }
666
667    /// How long a single fetch **attempt** may take before it is given up on.
668    ///
669    /// The deadline for one attempt, excluding retries the underlying client
670    /// performs — the sentence every store in this family answers to, and the
671    /// one place in the family where the exclusion is not a technicality.
672    ///
673    /// The AWS SDK retries on its own. So this maps onto
674    /// `operation_attempt_timeout`, which is per attempt, and a fetch can take
675    /// this multiplied by the attempt count — three, by default. That is
676    /// documented rather than tuned away: the SDK's retry policy is a
677    /// deployment's decision, and silently disabling it here would be this
678    /// crate overruling it. Set `operation_timeout` on the `SdkConfig` for a
679    /// ceiling on the whole call, or a retry policy for a different multiplier.
680    ///
681    /// The SDK has no timeout set at all by default, so this is additive:
682    /// nothing that worked before starts failing, and a fetch that used to
683    /// hang now stops.
684    #[must_use]
685    pub fn with_timeout(mut self, timeout: Duration) -> Self {
686        // Built from what the config already carries, so a caller's own
687        // connect or read timeouts survive setting this one.
688        let timeouts = self
689            .client
690            .config()
691            .timeout_config()
692            .map_or_else(TimeoutConfig::builder, TimeoutConfig::to_builder)
693            .operation_attempt_timeout(timeout)
694            .build();
695
696        let config = self
697            .client
698            .config()
699            .to_builder()
700            .timeout_config(timeouts)
701            .build();
702
703        self.client = Client::from_conf(config);
704        self
705    }
706
707    /// Calls `on_change` when the object's ETag moves, checking every
708    /// `interval`.
709    ///
710    /// Polling, because S3 offers nothing better without a notification
711    /// pipeline — and *ETag* polling, because downloading an object every
712    /// thirty seconds to discover it has not changed is a poor thing to do to a
713    /// bucket that charges per gigabyte. Each tick is a `HEAD`; only a new ETag
714    /// costs a `GET`.
715    ///
716    /// The current value is **not** delivered at startup, for the same reason a
717    /// file watcher does not report an edit when it starts. Fetch first if the
718    /// starting value matters.
719    ///
720    /// A failed check does not end the watch — an expired credential, a network
721    /// blip, a bucket briefly unreachable — it waits out the interval and tries
722    /// again. `stop` is noticed within a quarter second whatever `interval` is.
723    ///
724    /// # What a failing loop reports
725    ///
726    /// Nothing, unless [`reporting_to`](Self::reporting_to) was given a sink.
727    /// With one, both failures **inside** the loop are reported to the
728    /// `RemoteStatus` as they happen: a `HEAD` that did not answer, and a
729    /// `GET` that did not answer after the ETag moved. Surviving a failure is
730    /// exactly what makes this necessary — a loop that retries forever is a
731    /// loop that reports nothing forever, and a poll silently failing since
732    /// Tuesday is indistinguishable from a configuration nobody has changed.
733    ///
734    /// The refusals **at the door** — no format, several keys — are not
735    /// reported: they are returned to the caller by this very call, before
736    /// there is a loop to be silent in, and they are deployment mistakes
737    /// rather than a store that stopped answering.
738    ///
739    /// # Errors
740    ///
741    /// If the key names no format and none was stated — a watch that cannot
742    /// parse what it fetches would poll forever and deliver nothing, so it
743    /// refuses at the start instead. If the source reads several keys: an ETag
744    /// belongs to an object, and a set of objects has none. Or if `on_change`
745    /// returns an error, which ends the watch. Transport failures do not
746    /// surface here; they are retried.
747    pub async fn watch<F>(
748        &self,
749        watching: &Watching,
750        interval: Duration,
751        mut on_change: F,
752    ) -> Result<(), Error>
753    where
754        F: FnMut(Fetched) -> Result<(), Error> + Send,
755    {
756        // Checked before the first tick: with no format every `read` inside
757        // the loop fails, and the `if let Ok` there — right for a transient
758        // network failure — would swallow a permanent configuration mistake.
759        // Both are returned and recorded nowhere: nothing has been asked of
760        // the bucket yet, and `reachable()` is *whether the store answered the
761        // last time it was asked*.
762        self.format()?;
763        // Refused up front for the same reason, so a multi-key source fails at
764        // `watch` rather than on the first change, hours later.
765        self.single_key()?;
766
767        let mut seen: Option<String> = None;
768
769        while watching.keep_going() {
770            match self.etag().await {
771                // The first tick records the tag without firing: the object it
772                // names is the one the caller already has.
773                Ok(tag) if seen.is_none() => seen = Some(tag),
774
775                Ok(tag) if seen.as_ref() != Some(&tag) => {
776                    // The tag is taken from the read itself rather than from
777                    // the check, so a write landing between the two is not
778                    // delivered twice.
779                    match self.read().await {
780                        Ok((document, current)) => {
781                            seen = current.or(Some(tag));
782
783                            guarded(&mut on_change, document, &self.describe())?;
784                        }
785                        // The `HEAD` answered and the `GET` did not, so the
786                        // object this loop exists to deliver has changed and
787                        // has not been delivered. `seen` is deliberately left
788                        // where it was, so the next tick tries the same tag
789                        // again rather than treating a failed read as read.
790                        Err(error) => self.attempts.failed(&error),
791                    }
792                }
793
794                // Unchanged: the object is what it was, which is a store
795                // answering. Nothing to report and nothing to deliver.
796                Ok(_) => {}
797
798                // The check itself failed — an expired credential, a bucket
799                // briefly unreachable. It does not end the watch, which is
800                // what makes reporting it the only way anyone learns that
801                // this loop has been polling into the void.
802                Err(error) => self.attempts.failed(&error),
803            }
804
805            sleep_while(interval, watching).await;
806        }
807
808        Ok(())
809    }
810
811    /// Sorts one of the SDK's failures into a kind.
812    ///
813    /// The code, not the status: S3 answers `AccessDenied` and
814    /// `RequestTimeTooSkewed` with the same 403, and only one of them is
815    /// something waiting cannot cure.
816    fn classified<E: ProvideErrorMetadata + std::fmt::Display>(&self, error: &E) -> Error {
817        let described = format!("{}: {error}", self.describe());
818
819        match error.code() {
820            Some(code) if AUTH_CODES.contains(&code) => Error::auth(described),
821            _ => Error::remote(described),
822        }
823    }
824
825    /// The object's ETag, which changes when its body does.
826    async fn etag(&self) -> Result<String, Error> {
827        let key = self.single_key()?;
828
829        let head = self
830            .client
831            .head_object()
832            .bucket(&self.bucket)
833            .key(key)
834            .send()
835            .await
836            .map_err(|error| self.classified(&error))?;
837
838        head.e_tag()
839            .map(str::to_owned)
840            .ok_or_else(|| Error::remote(format!("{}: the object has no ETag", self.describe())))
841    }
842
843    /// The one object a watch follows, and the ETag it was read at.
844    async fn read(&self) -> Result<(Fetched, Option<String>), Error> {
845        let format = self.format()?;
846        let key = self.single_key()?;
847
848        let (text, tag) = self.object(key).await?;
849
850        Ok((Fetched::new(text, format), tag))
851    }
852
853    /// One object's body, and its ETag.
854    async fn object(&self, key: &str) -> Result<(String, Option<String>), Error> {
855        let object = self
856            .client
857            .get_object()
858            .bucket(&self.bucket)
859            .key(key)
860            .send()
861            .await
862            .map_err(|error| self.classified(&error))?;
863
864        let tag = object.e_tag().map(str::to_owned);
865
866        let bytes = object
867            .body
868            .collect()
869            .await
870            .map_err(|error| Error::remote(format!("{}: `{key}`: {error}", self.describe())))?
871            .into_bytes();
872
873        let text = String::from_utf8(bytes.to_vec()).map_err(|error| {
874            Error::remote(format!(
875                "{}: `{key}` is not UTF-8: {error}",
876                self.describe()
877            ))
878        })?;
879
880        Ok((text, tag))
881    }
882
883    /// The `(key, document)` pairs this source reads, in merge order.
884    ///
885    /// Every key must answer: merging the four that did would leave a process
886    /// running a configuration with a section quietly missing from it.
887    async fn documents(&self) -> Result<Vec<(String, String)>, Error> {
888        let keys = match &self.keys {
889            Keys::One(key) => vec![key.clone()],
890            Keys::Several(keys) => keys.clone(),
891            Keys::Prefix(prefix) => self.listed(prefix).await?,
892        };
893
894        // A prefix that matched nothing is a missing configuration rather than
895        // an empty one, and saying so here beats an empty merge's vaguer word.
896        if keys.is_empty() {
897            return Err(Error::remote(format!(
898                "{}: nothing matched, so there is nothing to load",
899                self.describe()
900            )));
901        }
902
903        let mut documents = Vec::with_capacity(keys.len());
904
905        for key in keys {
906            let (text, _tag) = self.object(&key).await?;
907
908            documents.push((key, text));
909        }
910
911        Ok(documents)
912    }
913
914    /// Every key under `prefix`, from `ListObjectsV2`.
915    ///
916    /// The budget is applied **to the listing**: a page is asked for one key
917    /// more than the budget allows, so a prefix pointed at a whole bucket is
918    /// refused after one request rather than after a million bodies. S3 lists
919    /// in UTF-8 order and pages continue where the last left off, so the
920    /// result is already sorted — which the prefix rule needs, because the
921    /// same set of keys has to produce the same document and the same
922    /// diagnostic every time.
923    async fn listed(&self, prefix: &str) -> Result<Vec<String>, Error> {
924        // One more than the budget, so the refusal happens on the count rather
925        // than on a page boundary: a bucket holding exactly the budget is
926        // allowed, and the first key past it is not.
927        let per_page = i32::try_from(MOST_KEYS + 1).unwrap_or(i32::MAX);
928
929        let mut found: Vec<String> = Vec::new();
930        let mut token: Option<String> = None;
931
932        for _ in 0..MOST_LIST_PAGES {
933            let page = self
934                .client
935                .list_objects_v2()
936                .bucket(&self.bucket)
937                .prefix(prefix)
938                .max_keys(per_page)
939                .set_continuation_token(token)
940                .send()
941                .await
942                .map_err(|error| self.classified(&error))?;
943
944            for object in page.contents() {
945                let Some(key) = object.key() else {
946                    continue;
947                };
948
949                // The store is not trusted to have honoured the prefix it was
950                // given: a proxy in front of it could rewrite the request, and
951                // the check is one comparison.
952                documents::under_prefix(key, prefix, &self.describe())?;
953
954                // The zero-byte object the console creates when somebody makes
955                // a "folder". It is not a missing document; it is not a
956                // document.
957                if key.ends_with('/') {
958                    continue;
959                }
960
961                found.push(key.to_owned());
962            }
963
964            // Checked per page, so the refusal costs one listing rather than
965            // every listing plus every body.
966            documents::within_key_budget(found.len(), &self.describe())?;
967
968            token = page.next_continuation_token().map(str::to_owned);
969
970            if token.is_none() {
971                return Ok(found);
972            }
973        }
974
975        Err(Error::remote(format!(
976            "{}: the listing did not finish in {MOST_LIST_PAGES} pages; \
977             the store is not advancing the continuation token",
978            self.describe()
979        )))
980    }
981}
982
983impl AsyncRemoteSource for S3 {
984    fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
985        Box::pin(async move {
986            let format = self.format()?;
987
988            let documents = self.documents().await?;
989
990            // A named list is read in call order and a listing arrives in
991            // UTF-8 order, which is what each rule wants — so nothing is
992            // reordered here.
993            documents::merged(&documents, format, self.overlap(), &self.describe())
994        })
995    }
996
997    fn describe(&self) -> String {
998        // The endpoint tells MinIO apart from AWS, and one MinIO from
999        // another — the detail that matters when an error says "no such
1000        // bucket" and there are three object stores it could mean.
1001        match &self.endpoint {
1002            Some(endpoint) => format!("s3 {endpoint} {}/{}", self.bucket, self.keys.describe()),
1003            None => format!("s3 {}/{}", self.bucket, self.keys.describe()),
1004        }
1005    }
1006}
1007
1008impl std::fmt::Debug for S3 {
1009    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1010        f.debug_struct("S3")
1011            .field("bucket", &self.bucket)
1012            .field("keys", &self.keys)
1013            .field("format", &self.format)
1014            .finish_non_exhaustive()
1015    }
1016}
1017
1018/// Sleeps in slices, so a stop is noticed inside the interval rather than after
1019/// it — a thirty-second poll should not mean a thirty-second exit.
1020async fn sleep_while(total: Duration, watching: &Watching) {
1021    const SLICE: Duration = Duration::from_millis(250);
1022
1023    let mut slept = Duration::ZERO;
1024
1025    while slept < total && watching.keep_going() {
1026        // `min`, so an interval below the slice sleeps what was asked, not a
1027        // silently rounded-up quarter second.
1028        tokio::time::sleep(SLICE.min(total - slept)).await;
1029        slept += SLICE;
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use std::io::{Read, Write};
1036    use std::net::TcpListener;
1037
1038    use aws_sdk_s3::config::retry::RetryConfig;
1039    use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
1040
1041    use super::*;
1042
1043    /// An S3 pointed at `endpoint`, with credentials that exist only here and
1044    /// the retry policy `retries` asks for.
1045    ///
1046    /// Static credentials rather than the environment's: a test that reads the
1047    /// ambient credential chain passes or fails according to whose laptop it
1048    /// is on. The retry policy is explicit for the same reason — the real
1049    /// construction paths inherit `aws-config`'s standard three attempts, and
1050    /// a test should say which number it is asserting about.
1051    fn against(endpoint: &str, retries: RetryConfig) -> S3 {
1052        let config = aws_sdk_s3::config::Builder::new()
1053            .behavior_version(BehaviorVersion::latest())
1054            .region(Region::new("us-east-1"))
1055            .endpoint_url(endpoint)
1056            .force_path_style(true)
1057            .retry_config(retries)
1058            .credentials_provider(Credentials::for_tests())
1059            .build();
1060
1061        S3::from_client(Client::from_conf(config), "myapp-config", "prod/db.json")
1062    }
1063
1064    /// Answers every request with `status` and `body`, and counts them.
1065    fn scripted(
1066        status: &'static str,
1067        body: impl Into<String>,
1068    ) -> (
1069        String,
1070        std::sync::Arc<std::sync::atomic::AtomicUsize>,
1071        std::thread::JoinHandle<()>,
1072    ) {
1073        use std::sync::atomic::{AtomicUsize, Ordering};
1074
1075        let body = body.into();
1076
1077        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1078        let endpoint = format!("http://{}", listener.local_addr().unwrap());
1079        let requests = std::sync::Arc::new(AtomicUsize::new(0));
1080
1081        let counter = std::sync::Arc::clone(&requests);
1082        let server = std::thread::spawn(move || {
1083            // Bounded: a refusal the SDK does not retry costs one request, and
1084            // the loop must not outlive the test either way. Above
1085            // `MOST_LIST_PAGES`, so the page cap is what ends a listing test
1086            // rather than the server running out of turns.
1087            for _ in 0..MOST_LIST_PAGES + 4 {
1088                let Ok((mut stream, _)) = listener.accept() else {
1089                    return;
1090                };
1091
1092                let mut seen = Vec::new();
1093                let mut byte = [0u8; 1];
1094
1095                while !seen.ends_with(b"\r\n\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
1096                    seen.push(byte[0]);
1097                }
1098
1099                counter.fetch_add(1, Ordering::SeqCst);
1100
1101                let response = format!(
1102                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: application/xml\r\nConnection: close\r\n\r\n{body}",
1103                    body.len()
1104                );
1105                let _ = stream.write_all(response.as_bytes());
1106            }
1107        });
1108
1109        (endpoint, requests, server)
1110    }
1111
1112    /// A `ListObjectsV2` answer naming `keys`, and truncated if `token` says
1113    /// where to carry on from.
1114    fn listing(keys: &[String], token: Option<&str>) -> String {
1115        let contents: String = keys
1116            .iter()
1117            .map(|key| format!("<Contents><Key>{key}</Key><Size>1</Size></Contents>"))
1118            .collect();
1119
1120        let truncation = match token {
1121            Some(token) => format!(
1122                "<IsTruncated>true</IsTruncated><NextContinuationToken>{token}</NextContinuationToken>"
1123            ),
1124            None => "<IsTruncated>false</IsTruncated>".to_owned(),
1125        };
1126
1127        format!(
1128            r#"<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>myapp-config</Name>{truncation}{contents}</ListBucketResult>"#
1129        )
1130    }
1131
1132    /// An S3 reading `keys`, against `endpoint`, with no retries.
1133    fn reading(endpoint: &str, keys: Keys) -> S3 {
1134        let config = aws_sdk_s3::config::Builder::new()
1135            .behavior_version(BehaviorVersion::latest())
1136            .region(Region::new("us-east-1"))
1137            .endpoint_url(endpoint)
1138            .force_path_style(true)
1139            .retry_config(RetryConfig::disabled())
1140            .credentials_provider(Credentials::for_tests())
1141            .build();
1142
1143        S3::from_client(Client::from_conf(config), "myapp-config", keys).with_format(Format::Json)
1144    }
1145
1146    /// The budget has to bite on the *listing*. A prefix pointed at a whole
1147    /// bucket must cost one request, not one request and half a million
1148    /// bodies — which is the difference between a refusal and an outage.
1149    #[tokio::test]
1150    async fn a_prefix_over_the_budget_is_refused_after_one_listing() {
1151        let keys: Vec<String> = (0..=MOST_KEYS)
1152            .map(|n| format!("prod/section-{n:04}.json"))
1153            .collect();
1154
1155        let (endpoint, requests, server) = scripted("200 OK", listing(&keys, None));
1156
1157        let error = reading(&endpoint, Keys::prefix("prod/"))
1158            .fetch()
1159            .await
1160            .expect_err("the prefix matches more keys than the budget allows");
1161
1162        drop(server);
1163
1164        assert!(error.to_string().contains("narrow the prefix"), "{error}");
1165        assert_eq!(
1166            requests.load(std::sync::atomic::Ordering::SeqCst),
1167            1,
1168            "the budget is checked on the listing, so not one body is fetched"
1169        );
1170    }
1171
1172    /// A store is not trusted to have honoured the prefix it was given: a
1173    /// proxy in front of it can rewrite a request, and a key from outside the
1174    /// prefix would put somebody else's document into this configuration.
1175    #[tokio::test]
1176    async fn a_key_the_store_answers_with_from_outside_the_prefix_is_refused() {
1177        let (endpoint, _requests, server) = scripted(
1178            "200 OK",
1179            listing(&["other-tenant/db.json".to_owned()], None),
1180        );
1181
1182        let error = reading(&endpoint, Keys::prefix("prod/"))
1183            .fetch()
1184            .await
1185            .expect_err("that key is not under the prefix that was asked for");
1186
1187        drop(server);
1188
1189        assert!(
1190            error.to_string().contains("other-tenant/db.json"),
1191            "{error}"
1192        );
1193        assert!(
1194            error.to_string().contains("not under the prefix"),
1195            "{error}"
1196        );
1197    }
1198
1199    /// A continuation token that never clears is a loop inside a fetch, and
1200    /// the budget on the keys cannot see it: the count never moves.
1201    #[tokio::test]
1202    async fn a_listing_that_never_finishes_is_given_up_on() {
1203        // Every page: no keys, and always another page to come.
1204        let (endpoint, requests, server) = scripted("200 OK", listing(&[], Some("always-more")));
1205
1206        let error = reading(&endpoint, Keys::prefix("prod/"))
1207            .fetch()
1208            .await
1209            .expect_err("the token never clears");
1210
1211        drop(server);
1212
1213        assert!(
1214            error.to_string().contains("not advancing the continuation"),
1215            "{error}"
1216        );
1217        assert_eq!(
1218            requests.load(std::sync::atomic::Ordering::SeqCst),
1219            MOST_LIST_PAGES,
1220            "the page budget is what ends it"
1221        );
1222    }
1223
1224    /// A watch compares ETags, and a set of objects has none. Refused at
1225    /// `watch`, so it fails now rather than in six hours by never firing.
1226    #[tokio::test]
1227    async fn a_multi_key_source_refuses_to_be_watched_and_says_what_to_do_instead() {
1228        let source = reading("http://127.0.0.1:9", Keys::several(["a.json", "b.json"]));
1229
1230        let watch = dynamic_config::RemoteWatch::new();
1231        let watching = watch.watching();
1232
1233        let error = source
1234            .watch(&watching, Duration::from_millis(50), |_| Ok(()))
1235            .await
1236            .expect_err("a merged document has no one ETag");
1237
1238        assert!(error.to_string().contains("several keys"), "{error}");
1239        assert!(
1240            error.to_string().contains("refresh_remote_async"),
1241            "{error}"
1242        );
1243    }
1244
1245    /// Two keys naming two formats is the confusing failure worth catching by
1246    /// name: `server.toml` parsed as JSON is a syntax error about a file that
1247    /// has no syntax error in it.
1248    #[tokio::test]
1249    async fn keys_naming_two_formats_are_reported_rather_than_guessed() {
1250        let config = aws_sdk_s3::config::Builder::new()
1251            .behavior_version(BehaviorVersion::latest())
1252            .region(Region::new("us-east-1"))
1253            .endpoint_url("http://127.0.0.1:9")
1254            .force_path_style(true)
1255            .retry_config(RetryConfig::disabled())
1256            .credentials_provider(Credentials::for_tests())
1257            .build();
1258
1259        let source = S3::from_client(
1260            Client::from_conf(config),
1261            "myapp-config",
1262            Keys::several(["prod/db.json", "prod/server.toml"]),
1263        );
1264
1265        let error = source.fetch().await.expect_err("two formats, one source");
1266
1267        assert!(error.to_string().contains("prod/db.json"), "{error}");
1268        assert!(error.to_string().contains("prod/server.toml"), "{error}");
1269        assert!(error.to_string().contains("with_format"), "{error}");
1270    }
1271
1272    /// The store saying no to the credentials, which no amount of waiting
1273    /// changes — so a watch loop should stop rather than back off.
1274    #[tokio::test]
1275    async fn access_denied_is_an_auth_failure() {
1276        let (endpoint, _requests, server) = scripted(
1277            "403 Forbidden",
1278            r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"#,
1279        );
1280
1281        let error = against(&endpoint, RetryConfig::disabled())
1282            .fetch()
1283            .await
1284            .expect_err("the store refused the credentials");
1285
1286        drop(server);
1287
1288        assert_eq!(error.kind(), dynamic_config::ErrorKind::Auth, "{error}");
1289        assert!(error.to_string().contains("prod/db.json"), "{error}");
1290    }
1291
1292    /// The same 403, a different code, and the opposite verdict: a clock too
1293    /// far out of step does come right, so classifying it `Auth` would stop a
1294    /// watch loop that NTP was about to fix.
1295    #[tokio::test]
1296    async fn a_skewed_clock_shares_the_403_and_stays_remote() {
1297        let (endpoint, _requests, server) = scripted(
1298            "403 Forbidden",
1299            r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>RequestTimeTooSkewed</Code><Message>The difference between the request time and the current time is too large.</Message></Error>"#,
1300        );
1301
1302        let error = against(&endpoint, RetryConfig::disabled())
1303            .fetch()
1304            .await
1305            .expect_err("the store refused the request");
1306
1307        drop(server);
1308
1309        assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
1310    }
1311
1312    /// A store that is simply not there is `Remote`, never `Auth`.
1313    #[tokio::test]
1314    async fn an_unreachable_store_is_remote_rather_than_auth() {
1315        // Port 9 is discard; nothing listens there.
1316        let error = against("http://127.0.0.1:9", RetryConfig::disabled())
1317            .with_timeout(Duration::from_millis(200))
1318            .fetch()
1319            .await
1320            .expect_err("nothing is listening");
1321
1322        assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
1323    }
1324
1325    /// `with_timeout` is per *attempt*, so the README's arithmetic — a
1326    /// timeout times the attempt count — is a tested claim rather than a
1327    /// hopeful one.
1328    #[tokio::test]
1329    async fn the_deadline_is_per_attempt_and_the_sdk_retries_underneath() {
1330        // Accepts and never answers: the failure only a per-attempt deadline
1331        // catches, and one the SDK treats as worth retrying.
1332        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1333        let endpoint = format!("http://{}", listener.local_addr().unwrap());
1334
1335        let silent = std::thread::spawn(move || {
1336            let mut held = Vec::new();
1337
1338            while held.len() < 3 {
1339                let Ok(accepted) = listener.accept() else {
1340                    return;
1341                };
1342                held.push(accepted);
1343            }
1344
1345            std::thread::sleep(Duration::from_secs(1));
1346        });
1347
1348        const ATTEMPT: Duration = Duration::from_millis(300);
1349
1350        // Three attempts, the number `aws-config` gives the real
1351        // construction paths, with the backoff shortened so the test spends
1352        // its time on the attempts rather than between them.
1353        let source = against(
1354            &endpoint,
1355            RetryConfig::standard()
1356                .with_max_attempts(3)
1357                .with_initial_backoff(Duration::from_millis(1)),
1358        )
1359        .with_timeout(ATTEMPT);
1360
1361        assert_eq!(
1362            source
1363                .client
1364                .config()
1365                .timeout_config()
1366                .and_then(aws_sdk_s3::config::timeout::TimeoutConfig::operation_attempt_timeout),
1367            Some(ATTEMPT),
1368            "the value has to reach the SDK, not merely be remembered here"
1369        );
1370
1371        let started = std::time::Instant::now();
1372        let error = source.fetch().await.expect_err("nothing ever answers");
1373        let elapsed = started.elapsed();
1374
1375        assert!(
1376            elapsed > ATTEMPT * 2,
1377            "the SDK retries beneath the per-attempt deadline, so the call \
1378             outlasts one attempt — that is the README's multiplier: {elapsed:?}"
1379        );
1380        assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
1381
1382        let _ = silent.join();
1383    }
1384
1385    // -----------------------------------------------------------------------
1386    // Reporting a failing watch
1387    //
1388    // One `#[dynamic_config]` type per test: the snapshot, the remote slot and
1389    // the sink's generation all live in statics keyed by the type, so two
1390    // tests sharing one would race and — worse — pass alone.
1391    // -----------------------------------------------------------------------
1392
1393    /// The failure nobody notices: a poll loop *survives* its failures by
1394    /// design, so a bucket that stopped answering on Tuesday looks exactly
1395    /// like a configuration nobody has changed since Tuesday.
1396    ///
1397    /// What the status must say afterwards is a *pair*: `reachable()` goes to
1398    /// `Some(false)` while `last_fetch` keeps the instant the last document
1399    /// really arrived — so an alert can ask "down, and stale for how long".
1400    /// A failure that reset the clock would hide the second half.
1401    #[tokio::test]
1402    async fn a_failing_poll_reports_the_store_as_down_and_leaves_the_clock_running() {
1403        use dynamic_config::dynamic_config;
1404
1405        #[dynamic_config]
1406        #[derive(Debug, serde::Deserialize)]
1407        struct Polled {
1408            // Never read: this test is about the status the store records,
1409            // not about the document, which never gets as far as a snapshot.
1410            #[allow(dead_code)]
1411            host: String,
1412        }
1413
1414        let (answering, _requests, answered) = scripted("200 OK", r#"{"db": {"host": "base"}}"#);
1415
1416        Polled::set_remote_async(against(&answering, RetryConfig::disabled()));
1417        Polled::refresh_remote_async()
1418            .await
1419            .expect("the store answers the first read");
1420
1421        // Taken after the source is installed, which is what fences it.
1422        let sink = Polled::remote_sink();
1423        let before = sink.status();
1424
1425        assert_eq!(before.reachable(), Some(true), "one fetch, and it answered");
1426        assert!(before.last_fetch.is_some());
1427
1428        // A second endpoint rather than a second mood: the store the watch
1429        // polls has started refusing, which is what an expired credential or
1430        // a gateway that went away looks like from inside the loop.
1431        let (refusing, _polls, refused) = scripted(
1432            "500 Internal Server Error",
1433            "<Error><Code>Internal</Code></Error>",
1434        );
1435
1436        let watcher = against(&refusing, RetryConfig::disabled())
1437            .with_timeout(Duration::from_millis(500))
1438            .reporting_to(sink);
1439
1440        let watch = dynamic_config::RemoteWatch::new();
1441        let watching = watch.watching();
1442
1443        let polling = tokio::spawn(async move {
1444            watcher
1445                .watch(&watching, Duration::from_millis(50), |_| Ok(()))
1446                .await
1447        });
1448
1449        let deadline = std::time::Instant::now() + Duration::from_secs(20);
1450
1451        while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
1452            tokio::time::sleep(Duration::from_millis(20)).await;
1453        }
1454
1455        let after = sink.status();
1456
1457        assert!(
1458            !polling.is_finished(),
1459            "a failed check does not end the watch — which is exactly why \
1460             reporting it is the only way anyone hears about it"
1461        );
1462        assert_eq!(
1463            after.reachable(),
1464            Some(false),
1465            "a loop polling into the void is a store that is down"
1466        );
1467        assert_eq!(
1468            after.last_fetch, before.last_fetch,
1469            "the staleness clock keeps running: `last_fetch` is when a document \
1470             last arrived, and a failed attempt is not one"
1471        );
1472        assert_eq!(
1473            after.fetches, before.fetches,
1474            "a failure is not a fetch, however it is counted elsewhere"
1475        );
1476        assert_eq!(
1477            after
1478                .last_failure
1479                .as_ref()
1480                .expect("a failure was recorded")
1481                .kind,
1482            dynamic_config::ErrorKind::Remote,
1483            "a store answering 500 may yet come back"
1484        );
1485
1486        // The recorded failure is a kind and a path, and the bucket, the key
1487        // and the endpoint are in none of them.
1488        let recorded = format!("{:?}", after.last_failure);
1489
1490        assert!(!recorded.contains("myapp-config"), "{recorded}");
1491        assert!(!recorded.contains("prod/db.json"), "{recorded}");
1492
1493        watch.stop();
1494        let _ = polling.await;
1495        drop((answered, refused));
1496    }
1497
1498    /// Answers every `HEAD` with an ETag — a new one after the first, so the
1499    /// object has changed — and every `GET` with a refusal.
1500    ///
1501    /// The shape of a bucket whose read policy went away, or of an object
1502    /// large enough that the transfer is the thing failing: the check the loop
1503    /// makes every tick keeps working, and the read it exists to make does
1504    /// not. Nothing about that reaches the caller, so nothing about it reaches
1505    /// anyone.
1506    fn heads_but_refuses_the_body() -> (String, std::thread::JoinHandle<()>) {
1507        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1508        let endpoint = format!("http://{}", listener.local_addr().unwrap());
1509
1510        let server = std::thread::spawn(move || {
1511            let mut heads = 0;
1512
1513            // Bounded, so the thread cannot outlive the test whatever the
1514            // loop does.
1515            for _ in 0..64 {
1516                let Ok((mut stream, _)) = listener.accept() else {
1517                    return;
1518                };
1519
1520                let mut seen = Vec::new();
1521                let mut byte = [0u8; 1];
1522
1523                while !seen.ends_with(b"\r\n\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
1524                    seen.push(byte[0]);
1525                }
1526
1527                let response = if String::from_utf8_lossy(&seen).starts_with("HEAD") {
1528                    heads += 1;
1529
1530                    // The first tick records the tag without reading. The
1531                    // second is the change this loop exists for.
1532                    let tag = if heads > 1 { "second" } else { "first" };
1533
1534                    format!(
1535                        "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nETag: \"{tag}\"\r\nConnection: close\r\n\r\n"
1536                    )
1537                } else {
1538                    let body = "<Error><Code>Internal</Code></Error>";
1539
1540                    format!(
1541                        "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1542                        body.len()
1543                    )
1544                };
1545
1546                if stream.write_all(response.as_bytes()).is_err() {
1547                    return;
1548                }
1549            }
1550        });
1551
1552        (endpoint, server)
1553    }
1554
1555    /// The second failure site, and the easier one to miss: the ETag moved, so
1556    /// the object *did* change, and the read that would have delivered it
1557    /// failed. The loop swallows that by design — it is the same `if let Ok`
1558    /// that makes a network blip survivable — so without a report the store
1559    /// looks healthy while the configuration it serves is frozen.
1560    #[tokio::test]
1561    async fn a_read_that_fails_after_the_tag_moved_is_reported_too() {
1562        use dynamic_config::dynamic_config;
1563
1564        #[dynamic_config]
1565        #[derive(Debug, serde::Deserialize)]
1566        struct Torn {
1567            // Never read, for the reason the test above gives.
1568            #[allow(dead_code)]
1569            host: String,
1570        }
1571
1572        let sink = Torn::remote_sink();
1573        let (endpoint, server) = heads_but_refuses_the_body();
1574
1575        let watcher = against(&endpoint, RetryConfig::disabled())
1576            .with_timeout(Duration::from_millis(500))
1577            .reporting_to(sink);
1578
1579        let watch = dynamic_config::RemoteWatch::new();
1580        let watching = watch.watching();
1581
1582        let polling = tokio::spawn(async move {
1583            watcher
1584                .watch(&watching, Duration::from_millis(50), |_| Ok(()))
1585                .await
1586        });
1587
1588        let deadline = std::time::Instant::now() + Duration::from_secs(20);
1589
1590        while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
1591            tokio::time::sleep(Duration::from_millis(20)).await;
1592        }
1593
1594        assert!(
1595            !polling.is_finished(),
1596            "a failed read does not end the watch either"
1597        );
1598        assert_eq!(
1599            sink.status().reachable(),
1600            Some(false),
1601            "the store answered the check and not the read, which is a store \
1602             this loop cannot get a document out of"
1603        );
1604        assert_eq!(
1605            sink.status().fetches,
1606            0,
1607            "nothing was delivered, so nothing is counted as a fetch"
1608        );
1609
1610        watch.stop();
1611        let _ = polling.await;
1612        // Dropped rather than joined: the server is parked in `accept` and no
1613        // connection is coming, so joining it would hang the suite.
1614        drop(server);
1615    }
1616
1617    /// A watch refused at the door is *not* a store that stopped answering.
1618    ///
1619    /// The reasoning is written out beside the same test in
1620    /// `dynamic-config-redis`, which is where 0.6.1's audit settled it for all
1621    /// seven stores: `reachable()` is *whether the store answered the last
1622    /// time it was asked*, and a source that names several keys never asks.
1623    #[tokio::test]
1624    async fn a_watch_refused_at_the_door_is_not_a_store_that_stopped_answering() {
1625        use dynamic_config::dynamic_config;
1626
1627        #[dynamic_config]
1628        #[derive(Debug, serde::Deserialize)]
1629        struct Doorstep {
1630            // Never read, for the reason the test above gives.
1631            #[allow(dead_code)]
1632            host: String,
1633        }
1634
1635        // No source is installed: a sink does not need one, and this is the
1636        // state a program that only ever watches is in.
1637        let sink = Doorstep::remote_sink();
1638
1639        let source =
1640            reading("http://127.0.0.1:9", Keys::several(["a.json", "b.json"])).reporting_to(sink);
1641
1642        let watch = dynamic_config::RemoteWatch::new();
1643        let error = source
1644            .watch(&watch.watching(), Duration::from_millis(50), |_| Ok(()))
1645            .await
1646            .expect_err("an ETag belongs to an object, and a set has none");
1647
1648        assert!(error.to_string().contains("cannot be watched"), "{error}");
1649        assert_eq!(
1650            sink.status().reachable(),
1651            None,
1652            "nothing has been asked of this store, so it is neither up nor down"
1653        );
1654    }
1655
1656    // -----------------------------------------------------------------------
1657    // TLS: the shared vocabulary, and the half the AWS SDK cannot express.
1658    // -----------------------------------------------------------------------
1659
1660    /// A certificate authority, generated here. A committed fixture expires,
1661    /// and a suite that fails on a date nobody chose is worse than one that
1662    /// costs a millisecond.
1663    fn authority() -> String {
1664        use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair};
1665
1666        let key = KeyPair::generate().unwrap();
1667        let mut params = CertificateParams::new(Vec::new()).unwrap();
1668        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
1669
1670        params.self_signed(&key).unwrap().pem()
1671    }
1672
1673    /// A trust store is what the SDK's TLS context has, so a certificate
1674    /// authority goes through — and construction still reaches no network.
1675    #[tokio::test]
1676    async fn a_private_authority_builds_a_client_and_touches_nothing() {
1677        let config = aws_config::SdkConfig::builder()
1678            .behavior_version(aws_config::BehaviorVersion::latest())
1679            .endpoint_url("https://minio.internal:9000")
1680            .build();
1681
1682        let source = S3::with_tls(
1683            &config,
1684            "myapp-config",
1685            "prod/db.json",
1686            &TlsConfig::new().with_ca_certificate_pem(authority()),
1687        )
1688        .expect("a trust store is what the SDK's TLS context holds");
1689
1690        assert!(
1691            source.describe().contains("minio.internal"),
1692            "the endpoint tells MinIO apart from AWS in an error: {}",
1693            source.describe()
1694        );
1695    }
1696
1697    /// The SDK's connector calls `.expect("cert parsable")` on this material,
1698    /// so a certificate it cannot read would be a panic at the first
1699    /// connection — a long way from the call that supplied it. Refused at
1700    /// construction instead, and without quoting what it choked on.
1701    #[tokio::test]
1702    async fn a_ca_certificate_the_sdk_would_panic_on_is_refused_at_construction() {
1703        let config = aws_config::SdkConfig::builder()
1704            .behavior_version(aws_config::BehaviorVersion::latest())
1705            .build();
1706
1707        let error = S3::with_tls(
1708            &config,
1709            "myapp-config",
1710            "prod/db.json",
1711            &TlsConfig::new().with_ca_certificate_pem(
1712                "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n",
1713            ),
1714        )
1715        .expect_err("the SDK would have panicked on this");
1716
1717        assert!(error.to_string().contains("not PEM-encoded"), "{error}");
1718    }
1719
1720    /// The one thing S3 cannot express. Refused rather than ignored: a caller
1721    /// who asked to present a certificate and did not would discover it as an
1722    /// authentication failure a long way from the cause.
1723    #[tokio::test]
1724    async fn a_client_certificate_is_refused_and_points_at_the_escape_hatch() {
1725        let config = aws_config::SdkConfig::builder()
1726            .behavior_version(aws_config::BehaviorVersion::latest())
1727            .build();
1728
1729        let error = S3::with_tls(
1730            &config,
1731            "myapp-config",
1732            "prod/db.json",
1733            &TlsConfig::new().with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
1734        )
1735        .expect_err("the SDK's TLS context has no client-certificate slot");
1736
1737        assert!(error.to_string().contains("client certificate"), "{error}");
1738        assert!(error.to_string().contains("from_client"), "{error}");
1739        assert!(
1740            error.to_string().contains("refused rather than ignored"),
1741            "{error}"
1742        );
1743    }
1744
1745    /// The private key is the sharpest secret here even where it is refused:
1746    /// the refusal must name the call and not the material.
1747    #[tokio::test]
1748    async fn the_client_certificate_refusal_never_quotes_the_key() {
1749        const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
1750
1751        let config = aws_config::SdkConfig::builder()
1752            .behavior_version(aws_config::BehaviorVersion::latest())
1753            .build();
1754
1755        let error = S3::with_tls(
1756            &config,
1757            "myapp-config",
1758            "prod/db.json",
1759            &TlsConfig::new().with_client_certificate_pem("cert", PLANTED),
1760        )
1761        .expect_err("the SDK's TLS context has no client-certificate slot");
1762
1763        assert!(!error.to_string().contains(PLANTED), "{error}");
1764        assert!(!format!("{error:?}").contains(PLANTED), "{error:?}");
1765    }
1766
1767    /// A CA file that is not there names the path, from the constructor
1768    /// rather than from a panic in a builder chain.
1769    #[tokio::test]
1770    async fn a_missing_ca_file_names_the_path_and_the_material() {
1771        let config = aws_config::SdkConfig::builder()
1772            .behavior_version(aws_config::BehaviorVersion::latest())
1773            .build();
1774
1775        let error = S3::with_tls(
1776            &config,
1777            "myapp-config",
1778            "prod/db.json",
1779            &TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem"),
1780        )
1781        .expect_err("the CA file is not there");
1782
1783        assert!(
1784            error.to_string().contains("/nonexistent/private-ca.pem"),
1785            "{error}"
1786        );
1787        assert!(error.to_string().contains("the CA certificate"), "{error}");
1788    }
1789}