shep_core/secrets/
refs.rs1use super::store::is_name;
2use crate::config::AppConfig;
3use crate::config::template;
4use core::fmt;
5use std::collections::BTreeSet;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct SecretRef<'a> {
13 pub namespace: Option<&'a str>,
15 pub key: &'a str,
17}
18
19impl<'a> SecretRef<'a> {
20 #[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#[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
87pub(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#[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#[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 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 #[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 #[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}