Skip to main content

shep_core/secrets/
refs.rs

1use super::store::is_name;
2use crate::config::AppConfig;
3use crate::config::template;
4use core::fmt;
5use std::collections::BTreeSet;
6
7/// One `{{secret:...}}` reference: a key, and the namespace it came from.
8///
9/// A bare reference reads the operator's own store; a namespaced one reads
10/// what the provider dog of that name pushed.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct SecretRef<'a> {
13    /// The provider dog's name, or `None` for the operator's own store.
14    pub namespace: Option<&'a str>,
15    /// The key within that store.
16    pub key: &'a str,
17}
18
19impl<'a> SecretRef<'a> {
20    /// Parses the body of a `{{secret:...}}` token, braces and prefix
21    /// already stripped.
22    ///
23    /// Returns `None` for anything outside the grammar, which is how a
24    /// config refuses a bad reference before a sheep ever starts.
25    #[must_use]
26    pub fn parse(body: &'a str) -> Option<Self> {
27        match body.split_once('/') {
28            None if is_name(body) => Some(Self {
29                namespace: None,
30                key: body,
31            }),
32            Some((namespace, key)) if is_name(namespace) && is_name(key) => Some(Self {
33                namespace: Some(namespace),
34                key,
35            }),
36            _ => None,
37        }
38    }
39}
40
41impl fmt::Display for SecretRef<'_> {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.write_str("{{secret:")?;
44        if let Some(namespace) = self.namespace {
45            f.write_str(namespace)?;
46            f.write_str("/")?;
47        }
48        f.write_str(self.key)?;
49        f.write_str("}}")
50    }
51}
52
53/// Every `{{secret:...}}` reference `config` names, exactly as the operator
54/// wrote it (`KEY` or `namespace/KEY`, no braces), deduplicated.
55///
56/// Walks `env`'s values, `args`, `out_file` and `err_file` through
57/// `template`'s own tokenizer, the same one [`template::render`] resolves
58/// against at spawn: a value this misses is one `render` would not touch
59/// either, and a positional token (`{{instance}}`, `{{name}}`) contributes
60/// nothing.
61#[must_use]
62pub fn references(config: &AppConfig) -> BTreeSet<String> {
63    let mut found = BTreeSet::new();
64    let mut scan = |value: &str| {
65        secret_references_in(value, |reference| {
66            found.insert(match reference.namespace {
67                Some(namespace) => format!("{namespace}/{}", reference.key),
68                None => reference.key.to_string(),
69            });
70        });
71    };
72    for value in config.env.values() {
73        scan(value);
74    }
75    for value in &config.args {
76        scan(value);
77    }
78    if let Some(value) = &config.out_file {
79        scan(value);
80    }
81    if let Some(value) = &config.err_file {
82        scan(value);
83    }
84    found
85}
86
87/// Walks `value` for every `{{secret:...}}` reference through `template`'s
88/// own tokenizer, calling `on_reference` for each one found.
89///
90/// The one tokenizer pass [`references`] and [`sealed_keys`] both build on,
91/// so a value either function accepts as naming a secret is one the other
92/// agrees with, rather than two spellings of the same check drifting apart.
93pub(super) fn secret_references_in(value: &str, mut on_reference: impl FnMut(SecretRef<'_>)) {
94    let _ = template::walk::<core::convert::Infallible>(value, |segment| {
95        if let template::Segment::Token(token) = segment
96            && let Some(reference) = template::secret_reference(token)
97        {
98            on_reference(reference);
99        }
100        Ok(())
101    });
102}
103
104/// The env keys whose value names at least one `{{secret:...}}` reference,
105/// in `env`'s own order.
106///
107/// The keys, never the references: [`references`] answers the other
108/// direction, and a pane that wants to mark a row as sealed needs this one.
109/// A value that merely embeds a reference counts, since the store is still
110/// what fills it in.
111///
112/// `env` alone. A reference in `args` or `out_file` is real and
113/// [`references`] reports it, but it is not an env row and nothing renders
114/// it as one.
115#[must_use]
116pub fn sealed_keys(config: &AppConfig) -> Vec<String> {
117    config
118        .env
119        .iter()
120        .filter(|(_, value)| {
121            let mut sealed = false;
122            secret_references_in(value, |_| sealed = true);
123            sealed
124        })
125        .map(|(key, _)| key.clone())
126        .collect()
127}
128
129/// The provider namespaces [`references`] names, derived with
130/// [`SecretRef::parse`] and kept to the namespaced half.
131///
132/// No I/O of its own: the seam boot-dependency ordering asks "which
133/// provider namespaces does this sheep depend on" through.
134#[must_use]
135pub fn namespaces_of(config: &AppConfig) -> BTreeSet<String> {
136    references(config)
137        .iter()
138        .filter_map(|reference| SecretRef::parse(reference))
139        .filter_map(|reference| reference.namespace.map(str::to_string))
140        .collect()
141}
142
143#[cfg(test)]
144mod tests {
145
146    use std::collections::BTreeSet;
147
148    use crate::config::AppConfig;
149
150    use super::*;
151
152    #[test]
153    fn a_reference_parses_with_and_without_a_namespace() {
154        let bare = SecretRef::parse("DB_PASSWORD").unwrap();
155        assert_eq!(bare.namespace, None);
156        assert_eq!(bare.key, "DB_PASSWORD");
157
158        let scoped = SecretRef::parse("vercel/DB_PASSWORD").unwrap();
159        assert_eq!(scoped.namespace, Some("vercel"));
160        assert_eq!(scoped.key, "DB_PASSWORD");
161
162        for bad in ["", "/KEY", "ns/", "a/b/c", "ns/bad key", "bad ns/KEY"] {
163            assert!(SecretRef::parse(bad).is_none(), "{bad:?} must not parse");
164        }
165    }
166
167    #[test]
168    fn references_finds_every_secret_in_a_config_and_nothing_else() {
169        let mut config = AppConfig::minimal("web", "./srv");
170        config.env.insert("A".into(), "{{secret:ONE}}".into());
171        config.env.insert("B".into(), "plain".into());
172        config
173            .env
174            .insert("C".into(), "{{name}}-{{secret:vercel/TWO}}".into());
175        config.args = vec!["--x={{secret:ONE}}".into()];
176        // Both log paths, and for opposite reasons. `err_file` carries a
177        // reference nothing else in this config names, so dropping the
178        // log-path scan fails here rather than passing quietly; `out_file`
179        // carries positional tokens only, so a scan that ran would still
180        // return nothing for it.
181        config.err_file = Some("{{secret:THREE}}.log".into());
182        config.out_file = Some("{{SHEP_HOME}}/logs/{{instance}}.log".into());
183
184        let found = references(&config);
185        assert_eq!(
186            found,
187            BTreeSet::from([
188                "ONE".to_string(),
189                "THREE".to_string(),
190                "vercel/TWO".to_string(),
191            ]),
192            "every field scanned, deduplicated, and no positional tokens"
193        );
194    }
195
196    /// The key, not the reference. `references` answers the other direction,
197    /// and a pane marking a row needs this one.
198    #[test]
199    fn a_sealed_key_is_reported_by_its_own_name() {
200        let mut config = AppConfig::minimal("web", "./srv");
201        config.env.insert("A".into(), "{{secret:ONE}}".into());
202        config.env.insert("B".into(), "literal".into());
203        assert_eq!(sealed_keys(&config), vec!["A".to_string()]);
204    }
205
206    /// Only `env`. `args` and `out_file` can name a reference too, and
207    /// `references` reports those; this function is about env rows.
208    #[test]
209    fn a_reference_outside_env_seals_no_key() {
210        let mut config = AppConfig::minimal("web", "./srv");
211        config.args = vec!["--token={{secret:ONE}}".into()];
212        assert!(sealed_keys(&config).is_empty());
213    }
214
215    #[test]
216    fn namespaces_of_a_config_is_the_seam_boot_ordering_will_want() {
217        let mut config = AppConfig::minimal("web", "./srv");
218        config.env.insert("A".into(), "{{secret:ONE}}".into());
219        config
220            .env
221            .insert("B".into(), "{{secret:vercel/TWO}}".into());
222        assert_eq!(
223            namespaces_of(&config),
224            BTreeSet::from(["vercel".to_string()])
225        );
226    }
227
228    #[test]
229    fn a_reference_displays_the_way_an_operator_wrote_it() {
230        assert_eq!(
231            SecretRef {
232                namespace: None,
233                key: "K"
234            }
235            .to_string(),
236            "{{secret:K}}"
237        );
238        assert_eq!(
239            SecretRef {
240                namespace: Some("vercel"),
241                key: "K"
242            }
243            .to_string(),
244            "{{secret:vercel/K}}"
245        );
246    }
247}