Skip to main content

rustlavel_vault/
resolve.rs

1//! Secrets that arrive as configuration.
2//!
3//! The point of the package in one line of `.env`:
4//!
5//! ```text
6//! DATABASE_URL=vault:secret/data/myapp#database_url
7//! ```
8//!
9//! At boot that reference is swapped for the value stored at
10//! `secret/data/myapp` under the field `database_url`, and nothing but the
11//! reference was ever written down. Every distinct path is fetched once however
12//! many fields are taken from it, because a secret with a username, a password
13//! and a host is one secret and should cost one round trip.
14//!
15//! **A reference that cannot be resolved is a hard error at boot.** Not an
16//! empty string, not a warning. An empty database password does not fail where
17//! it was configured; it fails minutes later inside a connection pool, with a
18//! message about authentication that names neither the secret nor the key, and
19//! somebody spends an afternoon on it. Failing at boot costs one restart and
20//! names both.
21//!
22//! For the same reason there is **no fallback syntax**. `vault:path#field|value`
23//! would be convenient and is exactly the feature that turns a store outage
24//! into a process running on the wrong credentials — the syntax cannot tell a
25//! harmless default apart from a password, so it is refused, with an error that
26//! explains why rather than a field name nobody stored.
27
28use crate::client::{VaultClient, VaultResponse};
29use crate::lease::Lease;
30use rustlavel_core::{Config, Error, Json, Result};
31use std::collections::BTreeMap;
32use std::future::Future;
33use std::pin::Pin;
34
35/// What marks a configuration value as a reference rather than a value.
36pub const PREFIX: &str = "vault:";
37
38/// A boxed future, so [`SecretSource`] can be used through a trait object.
39pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
40
41/// A `vault:` reference: which secret, and which field inside it.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SecretRef {
44    pub path: String,
45    pub field: String,
46}
47
48impl SecretRef {
49    /// Whether a value is meant as a reference at all.
50    ///
51    /// Only an exact `vault:` prefix counts, which is what keeps an ordinary
52    /// value that merely contains a colon — `postgres://db:5432/app`,
53    /// `redis://cache:6379` — from being mistaken for one.
54    pub fn is_reference(value: &str) -> bool {
55        value.starts_with(PREFIX)
56    }
57
58    /// Parse one, or say precisely what is wrong with it.
59    pub fn parse(value: &str) -> Result<SecretRef> {
60        let rest = value.strip_prefix(PREFIX).ok_or_else(|| {
61            Error::msg(format!("`{value}` is not a secret reference; one starts with `{PREFIX}`"))
62        })?;
63
64        // Checked before the split so `vault:path|default` — no field at all —
65        // gets the explanation too, rather than the generic "needs a field".
66        if rest.contains('|') {
67            return Err(Error::msg(format!(
68                "`{value}` looks like it carries a fallback, and there is deliberately no \
69                 fallback syntax. A default that stands in for a secret the store did not give \
70                 up means booting on the wrong credential and failing later somewhere else; if \
71                 the value is harmless enough to have a default, it is not a secret and belongs \
72                 in configuration"
73            )));
74        }
75
76        let (path, field) = rest.split_once('#').ok_or_else(|| {
77            Error::msg(format!(
78                "`{value}` names a path but no field. A reference reads \
79                 `vault:secret/data/myapp#database_url` — the part after `#` is the field \
80                 inside the secret"
81            ))
82        })?;
83
84        let (path, field) = (path.trim(), field.trim());
85        if path.is_empty() || field.is_empty() {
86            return Err(Error::msg(format!(
87                "`{value}` is not a complete secret reference: it needs a path and a field, as \
88                 in `vault:secret/data/myapp#database_url`"
89            )));
90        }
91
92        Ok(SecretRef { path: path.to_string(), field: field.to_string() })
93    }
94}
95
96impl std::fmt::Display for SecretRef {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        write!(f, "{PREFIX}{}#{}", self.path, self.field)
99    }
100}
101
102/// The fields stored at one path.
103///
104/// No `#[derive(Debug)]`, and there will not be one: this is the value the
105/// whole package exists to keep out of logs, and a derived `Debug` is how it
106/// reaches one — through a `dbg!` left behind, a panic message, or a struct
107/// somewhere upstream that derived `Debug` over a field holding this.
108#[derive(Clone)]
109pub struct Secret {
110    path: String,
111    fields: BTreeMap<String, String>,
112    lease: Lease,
113}
114
115impl Secret {
116    pub fn new(
117        path: impl Into<String>,
118        fields: BTreeMap<String, String>,
119        lease: Lease,
120    ) -> Secret {
121        Secret { path: path.into(), fields, lease }
122    }
123
124    /// Unwrap whatever the store answered a read with.
125    pub fn from_response(path: &str, response: &VaultResponse) -> Secret {
126        Secret {
127            path: path.to_string(),
128            fields: fields_of(&response.data),
129            lease: response.lease.clone(),
130        }
131    }
132
133    pub fn path(&self) -> &str {
134        &self.path
135    }
136
137    pub fn field(&self, name: &str) -> Option<&str> {
138        self.fields.get(name).map(String::as_str)
139    }
140
141    /// The field names, which are safe to print — and the single most useful
142    /// thing to say when a reference names a field that is not there.
143    pub fn names(&self) -> Vec<&str> {
144        self.fields.keys().map(String::as_str).collect()
145    }
146
147    pub fn lease(&self) -> &Lease {
148        &self.lease
149    }
150
151    pub fn is_empty(&self) -> bool {
152        self.fields.is_empty()
153    }
154}
155
156/// Names, never values.
157impl std::fmt::Debug for Secret {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("Secret")
160            .field("path", &self.path)
161            .field("fields", &self.names())
162            .field("values", &"<redacted>")
163            .finish()
164    }
165}
166
167/// Where a resolver gets its secrets.
168///
169/// One method on purpose. The KV engine, a fake, or an application's own
170/// wrapper around something this crate has never heard of can all satisfy it,
171/// and the resolver never learns which mount or which engine version it is
172/// reading from.
173pub trait SecretSource: Send + Sync {
174    fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>>;
175}
176
177impl SecretSource for VaultClient {
178    fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>> {
179        Box::pin(async move { Ok(Secret::from_response(path, &self.get(path).await?)) })
180    }
181}
182
183/// Resolves references, fetching each distinct path once.
184pub struct Resolver<'a> {
185    source: &'a dyn SecretSource,
186    fetched: BTreeMap<String, Secret>,
187}
188
189impl<'a> Resolver<'a> {
190    pub fn new(source: &'a dyn SecretSource) -> Resolver<'a> {
191        Resolver { source, fetched: BTreeMap::new() }
192    }
193
194    /// How many distinct paths have been fetched.
195    pub fn fetches(&self) -> usize {
196        self.fetched.len()
197    }
198
199    /// Resolve one value, or `None` if it was not a reference.
200    pub async fn value(&mut self, key: &str, value: &str) -> Result<Option<String>> {
201        if !SecretRef::is_reference(value) {
202            return Ok(None);
203        }
204        let reference = parse_for(key, value)?;
205        Ok(Some(self.field(key, &reference).await?))
206    }
207
208    /// Resolve every reference in a flat map, in place, and say how many.
209    ///
210    /// This is the shape a parsed `.env` comes in, and the shape anything else
211    /// keyed by name can be put into.
212    pub async fn pairs(&mut self, pairs: &mut BTreeMap<String, String>) -> Result<usize> {
213        let found: Vec<(String, String)> = pairs
214            .iter()
215            .filter(|(_, value)| SecretRef::is_reference(value))
216            .map(|(key, value)| (key.clone(), value.clone()))
217            .collect();
218
219        for (key, raw) in &found {
220            let reference = parse_for(key, raw)?;
221            let value = self.field(key, &reference).await?;
222            pairs.insert(key.clone(), value);
223        }
224
225        Ok(found.len())
226    }
227
228    /// Resolve every reference under each of these configuration paths.
229    ///
230    /// The roots have to be named because [`Config`] offers no way to
231    /// enumerate its top level, and a resolver that guessed at namespaces
232    /// would be the runtime magic this framework does without. Each root is a
233    /// dotted path and its whole subtree is searched, so `"database"` covers
234    /// `database.connections.pgsql.password`.
235    pub async fn config(&mut self, config: &Config, roots: &[&str]) -> Result<usize> {
236        let mut resolved = 0;
237
238        for root in roots {
239            let Some(tree) = config.get(root) else { continue };
240
241            let mut found = Vec::new();
242            collect(root, &tree, &mut found);
243            if found.is_empty() {
244                continue;
245            }
246
247            let mut values = BTreeMap::new();
248            for (key, raw) in &found {
249                let reference = parse_for(key, raw)?;
250                values.insert(key.clone(), self.field(key, &reference).await?);
251            }
252
253            resolved += values.len();
254            // Written back as one subtree rather than key by key, so a
255            // reference sitting inside an array survives as an array element
256            // instead of turning the array into an object.
257            config.set(root, substitute(root, tree, &values));
258        }
259
260        Ok(resolved)
261    }
262
263    /// The value of one field, fetching the secret if this is the first key to
264    /// ask for it.
265    async fn field(&mut self, key: &str, reference: &SecretRef) -> Result<String> {
266        if !self.fetched.contains_key(&reference.path) {
267            let secret = self.source.read(&reference.path).await.map_err(|error| {
268                Error::msg(format!("could not resolve `{key}` from `{reference}`: {error}"))
269            })?;
270            self.fetched.insert(reference.path.clone(), secret);
271        }
272
273        let secret = &self.fetched[&reference.path];
274        secret.field(&reference.field).map(str::to_string).ok_or_else(|| {
275            let names = secret.names();
276            let holds = if names.is_empty() {
277                "nothing at all".to_string()
278            } else {
279                names.join(", ")
280            };
281            Error::msg(format!(
282                "`{key}` refers to `{reference}`, but the secret at `{}` has no field \
283                 `{}` — it holds: {holds}",
284                reference.path, reference.field
285            ))
286        })
287    }
288}
289
290/// Resolve every reference in the process environment, in place.
291///
292/// Called before the application boots, so `Config::with_defaults` and every
293/// `env(...)` lookup afterwards see values rather than references.
294///
295/// Worth knowing what this costs: an environment variable is inherited by every
296/// child process and is readable from `/proc/self/environ` on Linux. It is
297/// still far better than a secret in a file that gets committed, and it is what
298/// `DATABASE_URL=vault:…` means — but an application that can read its secrets
299/// through [`Resolver::config`] instead should.
300///
301/// # Panics in the wrong hands
302///
303/// This writes the process environment, which is only sound while nothing else
304/// is reading it. Call it once, before serving anything; never from a handler,
305/// a job, or anything else running alongside live requests.
306pub async fn resolve_env(source: &dyn SecretSource) -> Result<usize> {
307    let mut pairs: BTreeMap<String, String> = std::env::vars()
308        .filter(|(_, value)| SecretRef::is_reference(value))
309        .collect();
310    if pairs.is_empty() {
311        return Ok(0);
312    }
313
314    let resolved = Resolver::new(source).pairs(&mut pairs).await?;
315    for (key, value) in &pairs {
316        // SAFETY: the caller must not have started anything that reads the
317        // environment. This is a weaker guarantee than the one
318        // `rustlavel_core::env::load` gives, and the difference is worth being
319        // straight about: `load` is synchronous and runs before the runtime
320        // exists, whereas this is `async` and therefore runs on one, so
321        // "single-threaded boot" is not something this function can promise.
322        // The contract is on the caller — resolve before serving, never
323        // afterwards — which is why [`Resolver::config`] exists and is the
324        // recommended path.
325        unsafe { std::env::set_var(key, value) };
326    }
327
328    Ok(resolved)
329}
330
331/// Parse a reference, naming the key it was written under.
332///
333/// The key is what the person editing the file recognises; the reference on its
334/// own leaves them searching for which line it came from.
335fn parse_for(key: &str, value: &str) -> Result<SecretRef> {
336    SecretRef::parse(value).map_err(|error| Error::msg(format!("`{key}`: {error}")))
337}
338
339/// Pull the fields out of the `data` object a read answered with.
340///
341/// Version 2 of the KV engine nests the secret one level down and puts a
342/// `metadata` object beside it; version 1 has the fields at the top. Choosing
343/// on the shape rather than making the caller declare which mount they are on
344/// keeps the mount version out of every reference in the config file.
345fn fields_of(data: &Json) -> BTreeMap<String, String> {
346    let inner = match (data.get("data"), data.get("metadata")) {
347        (Some(nested @ Json::Object(_)), Some(_)) => nested,
348        _ => data,
349    };
350
351    inner
352        .as_object()
353        .map(|map| {
354            map.iter()
355                .filter_map(|(name, value)| text(value).map(|value| (name.clone(), value)))
356                .collect()
357        })
358        .unwrap_or_default()
359}
360
361/// A scalar as its text. A nested object or array is skipped rather than
362/// serialised: a reference names one field, and JSON in a config string is
363/// almost always a mistake nobody wants substituted silently.
364fn text(value: &Json) -> Option<String> {
365    match value {
366        Json::String(value) => Some(value.clone()),
367        Json::Number(value) => Some(value.to_string()),
368        Json::Bool(value) => Some(value.to_string()),
369        _ => None,
370    }
371}
372
373/// Every reference in a tree, keyed by the dotted path it sits at.
374fn collect(prefix: &str, value: &Json, out: &mut Vec<(String, String)>) {
375    match value {
376        Json::String(value) if SecretRef::is_reference(value) => {
377            out.push((prefix.to_string(), value.clone()));
378        }
379        Json::Object(map) => {
380            for (name, value) in map {
381                collect(&format!("{prefix}.{name}"), value, out);
382            }
383        }
384        Json::Array(items) => {
385            for (index, value) in items.iter().enumerate() {
386                collect(&format!("{prefix}.{index}"), value, out);
387            }
388        }
389        _ => {}
390    }
391}
392
393/// The same tree with each collected reference replaced.
394fn substitute(prefix: &str, value: Json, values: &BTreeMap<String, String>) -> Json {
395    match value {
396        Json::String(ref text) if SecretRef::is_reference(text) => match values.get(prefix) {
397            Some(resolved) => Json::String(resolved.clone()),
398            None => value,
399        },
400        Json::Object(map) => Json::Object(
401            map.into_iter()
402                .map(|(name, value)| {
403                    let child = substitute(&format!("{prefix}.{name}"), value, values);
404                    (name, child)
405                })
406                .collect(),
407        ),
408        Json::Array(items) => Json::Array(
409            items
410                .into_iter()
411                .enumerate()
412                .map(|(index, value)| substitute(&format!("{prefix}.{index}"), value, values))
413                .collect(),
414        ),
415        other => other,
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::fake::Fake;
423
424    #[test]
425    fn a_value_that_merely_contains_a_colon_is_not_a_reference() {
426        // The whole reason the prefix is exact: a connection string is full of
427        // colons and must survive untouched.
428        assert!(!SecretRef::is_reference("postgres://user:pass@db:5432/app"));
429        assert!(!SecretRef::is_reference("redis://cache:6379"));
430        assert!(!SecretRef::is_reference("https://vault.internal:8200"));
431        assert!(!SecretRef::is_reference("not-vault:secret/data/x#y"));
432        assert!(SecretRef::is_reference("vault:secret/data/x#y"));
433    }
434
435    #[test]
436    fn parses_a_reference_into_a_path_and_a_field() {
437        let parsed = SecretRef::parse("vault:secret/data/myapp#database_url").unwrap();
438
439        assert_eq!(parsed.path, "secret/data/myapp");
440        assert_eq!(parsed.field, "database_url");
441        assert_eq!(parsed.to_string(), "vault:secret/data/myapp#database_url");
442    }
443
444    #[test]
445    fn a_reference_with_no_field_says_what_one_looks_like() {
446        let error = SecretRef::parse("vault:secret/data/myapp").unwrap_err().to_string();
447
448        assert!(error.contains("no field"), "got {error}");
449        assert!(error.contains("vault:secret/data/myapp#database_url"), "got {error}");
450    }
451
452    #[test]
453    fn an_empty_path_or_field_is_malformed() {
454        assert!(SecretRef::parse("vault:#field").is_err());
455        assert!(SecretRef::parse("vault:secret/data/x#").is_err());
456        assert!(SecretRef::parse("vault:").is_err());
457    }
458
459    #[test]
460    fn a_fallback_is_refused_and_the_error_argues_the_case() {
461        let error =
462            SecretRef::parse("vault:secret/data/myapp#password|hunter2").unwrap_err().to_string();
463
464        assert!(error.contains("fallback"), "got {error}");
465        assert!(error.contains("wrong credential"), "the error has to make the case: {error}");
466        // Without a field either, so the fallback explanation still wins.
467        assert!(SecretRef::parse("vault:secret/data/myapp|hunter2").unwrap_err().to_string().contains("fallback"));
468    }
469
470    fn store() -> crate::fake::FakeVault {
471        Fake::new()
472            .secret(
473                "secret/data/myapp",
474                [
475                    ("database_url", "postgres://app:s3cr3t@db:5432/app"),
476                    ("username", "app"),
477                    ("password", "s3cr3t"),
478                ],
479            )
480            .secret("secret/data/mail", [("password", "mailpass")])
481            .store()
482    }
483
484    #[tokio::test]
485    async fn a_reference_resolves_into_configuration() {
486        let store = store();
487        let config = Config::new();
488        config.set("database.url", "vault:secret/data/myapp#database_url");
489        config.set("database.pool", 10);
490
491        let resolved = Resolver::new(&store).config(&config, &["database"]).await.unwrap();
492
493        assert_eq!(resolved, 1);
494        assert_eq!(config.string("database.url", ""), "postgres://app:s3cr3t@db:5432/app");
495        assert_eq!(config.int("database.pool", 0), 10, "everything else is left alone");
496    }
497
498    #[tokio::test]
499    async fn several_keys_from_one_path_are_fetched_once() {
500        let store = store();
501        let config = Config::new();
502        config.set("database.username", "vault:secret/data/myapp#username");
503        config.set("database.password", "vault:secret/data/myapp#password");
504        config.set("database.url", "vault:secret/data/myapp#database_url");
505        config.set("mail.password", "vault:secret/data/mail#password");
506
507        let mut resolver = Resolver::new(&store);
508        let resolved = resolver.config(&config, &["database", "mail"]).await.unwrap();
509
510        assert_eq!(resolved, 4);
511        assert_eq!(resolver.fetches(), 2, "two paths, four keys");
512        store.assert_read_once("secret/data/myapp");
513        store.assert_read_once("secret/data/mail");
514        assert_eq!(config.string("database.password", ""), "s3cr3t");
515    }
516
517    #[tokio::test]
518    async fn an_unresolvable_reference_stops_the_boot_and_names_both() {
519        let store = Fake::new().missing("secret/data/nope").store();
520        let config = Config::new();
521        config.set("database.url", "vault:secret/data/nope#database_url");
522
523        let error = Resolver::new(&store)
524            .config(&config, &["database"])
525            .await
526            .expect_err("an unresolved secret must not boot")
527            .to_string();
528
529        assert!(error.contains("database.url"), "the key has to be named: {error}");
530        assert!(error.contains("secret/data/nope"), "the path has to be named: {error}");
531        // And nothing was substituted, least of all an empty string.
532        assert_eq!(config.string("database.url", ""), "vault:secret/data/nope#database_url");
533    }
534
535    #[tokio::test]
536    async fn a_missing_field_says_which_fields_the_secret_does_have() {
537        let store = store();
538        let config = Config::new();
539        config.set("database.url", "vault:secret/data/myapp#dsn");
540
541        let error =
542            Resolver::new(&store).config(&config, &["database"]).await.unwrap_err().to_string();
543
544        assert!(error.contains("no field `dsn`"), "got {error}");
545        assert!(error.contains("database_url"), "the names it does hold are the useful part: {error}");
546        assert!(!error.contains("s3cr3t"), "an error must never carry a value: {error}");
547    }
548
549    #[tokio::test]
550    async fn a_malformed_reference_is_rejected_before_anything_is_fetched() {
551        let store = store();
552        let config = Config::new();
553        config.set("database.url", "vault:secret/data/myapp");
554
555        let error =
556            Resolver::new(&store).config(&config, &["database"]).await.unwrap_err().to_string();
557
558        assert!(error.contains("database.url"), "got {error}");
559        assert!(error.contains("no field"), "got {error}");
560        store.assert_reads(0);
561    }
562
563    #[tokio::test]
564    async fn resolves_a_flat_map_the_way_a_dotenv_file_comes_in() {
565        let store = store();
566        let mut pairs = BTreeMap::from([
567            ("DATABASE_URL".to_string(), "vault:secret/data/myapp#database_url".to_string()),
568            ("MAIL_PASSWORD".to_string(), "vault:secret/data/mail#password".to_string()),
569            ("APP_URL".to_string(), "https://example.com:443".to_string()),
570        ]);
571
572        let resolved = Resolver::new(&store).pairs(&mut pairs).await.unwrap();
573
574        assert_eq!(resolved, 2);
575        assert_eq!(pairs["DATABASE_URL"], "postgres://app:s3cr3t@db:5432/app");
576        assert_eq!(pairs["MAIL_PASSWORD"], "mailpass");
577        assert_eq!(pairs["APP_URL"], "https://example.com:443");
578    }
579
580    #[tokio::test]
581    async fn a_reference_nested_in_an_object_or_an_array_is_found_and_kept_in_shape() {
582        let store = store();
583        let config = Config::new();
584        config.set(
585            "database.connections.pgsql",
586            Json::object([("password", Json::from("vault:secret/data/myapp#password"))]),
587        );
588        config.set(
589            "database.hosts",
590            Json::Array(vec![
591                Json::from("db-1"),
592                Json::from("vault:secret/data/myapp#username"),
593            ]),
594        );
595
596        Resolver::new(&store).config(&config, &["database"]).await.unwrap();
597
598        assert_eq!(config.string("database.connections.pgsql.password", ""), "s3cr3t");
599        let hosts = config.get("database.hosts").unwrap();
600        assert!(matches!(hosts, Json::Array(_)), "an array must stay an array: {hosts:?}");
601        assert_eq!(hosts.as_array().unwrap()[1].as_str(), Some("app"));
602    }
603
604    #[tokio::test]
605    async fn reads_a_version_1_secret_as_readily_as_a_version_2_one() {
606        // The mount version is not something a reference in a config file
607        // should have to know.
608        let store = Fake::new()
609            .answers(
610                "kv/legacy",
611                Json::object([("data", Json::object([("password", Json::from("v1pass"))]))]),
612            )
613            .store();
614
615        let mut pairs =
616            BTreeMap::from([("PASSWORD".to_string(), "vault:kv/legacy#password".to_string())]);
617        Resolver::new(&store).pairs(&mut pairs).await.unwrap();
618
619        assert_eq!(pairs["PASSWORD"], "v1pass");
620    }
621
622    #[tokio::test]
623    async fn a_number_or_a_flag_in_a_secret_becomes_its_text() {
624        let store = Fake::new()
625            .answers(
626                "secret/data/db",
627                Json::object([(
628                    "data",
629                    Json::object([
630                        ("data", Json::object([("port", Json::from(5432)), ("tls", Json::from(true))])),
631                        ("metadata", Json::object([("version", Json::from(1))])),
632                    ]),
633                )]),
634            )
635            .store();
636
637        let mut pairs = BTreeMap::from([
638            ("PORT".to_string(), "vault:secret/data/db#port".to_string()),
639            ("TLS".to_string(), "vault:secret/data/db#tls".to_string()),
640        ]);
641        Resolver::new(&store).pairs(&mut pairs).await.unwrap();
642
643        assert_eq!(pairs["PORT"], "5432");
644        assert_eq!(pairs["TLS"], "true");
645    }
646
647    #[tokio::test]
648    async fn a_secret_never_prints_its_values() {
649        let store = store();
650        let secret = store.read("secret/data/myapp").await.unwrap();
651
652        let printed = format!("{secret:?}");
653        assert!(!printed.contains("s3cr3t"), "a value reached a log: {printed}");
654        assert!(printed.contains("redacted"));
655        assert!(printed.contains("password"), "the names are what makes it useful: {printed}");
656    }
657
658    /// The one test in this crate that touches the process environment.
659    ///
660    /// Written against a runtime of its own rather than with `#[tokio::test]`
661    /// so the lock is held across a blocking call rather than an await: the
662    /// environment is process-wide, and two tests writing it at once is
663    /// exactly what `set_var` is unsafe about.
664    #[test]
665    fn resolves_references_in_the_process_environment() {
666        static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(());
667        let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
668
669        // SAFETY: guarded above, and no other test in this crate reads or
670        // writes the environment.
671        unsafe {
672            std::env::set_var("RUSTLAVEL_VAULT_TEST_URL", "vault:secret/data/myapp#database_url");
673            std::env::set_var("RUSTLAVEL_VAULT_TEST_PLAIN", "redis://cache:6379");
674        }
675
676        let runtime = tokio::runtime::Builder::new_current_thread()
677            .enable_all()
678            .build()
679            .expect("a runtime for one test");
680        let resolved = runtime.block_on(async { resolve_env(&store()).await.unwrap() });
681
682        assert!(resolved >= 1);
683        assert_eq!(
684            std::env::var("RUSTLAVEL_VAULT_TEST_URL").unwrap(),
685            "postgres://app:s3cr3t@db:5432/app"
686        );
687        assert_eq!(std::env::var("RUSTLAVEL_VAULT_TEST_PLAIN").unwrap(), "redis://cache:6379");
688
689        // SAFETY: as above.
690        unsafe {
691            std::env::remove_var("RUSTLAVEL_VAULT_TEST_URL");
692            std::env::remove_var("RUSTLAVEL_VAULT_TEST_PLAIN");
693        }
694    }
695
696    #[tokio::test]
697    async fn a_configuration_root_that_holds_nothing_is_not_an_error() {
698        let store = store();
699        let config = Config::new();
700
701        assert_eq!(Resolver::new(&store).config(&config, &["database"]).await.unwrap(), 0);
702        store.assert_reads(0);
703    }
704}