shep_core/secrets/provider.rs
1use core::fmt;
2use serde::Deserialize;
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6/// `key -> environment -> value`, one provider dog's pushed values.
7pub type NamespaceTable = BTreeMap<String, BTreeMap<String, String>>;
8
9/// `namespace -> key -> environment -> value`, every provider dog's pushed
10/// values.
11pub type NamespaceValues = BTreeMap<String, NamespaceTable>;
12
13/// `namespace -> environments`, the pairs a provider dog has actually
14/// pushed for.
15///
16/// A push carries one namespace and one environment
17/// (`Request::PutSecrets`), so a provider that has pushed `production` and
18/// not yet `staging` has one entry here, not two. That distinction is what
19/// [`Resolution::MissingNamespace`](crate::secrets::Resolution::MissingNamespace) is keyed on.
20pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
21
22/// What provider dogs have pushed: the values, and which
23/// `(namespace, environment)` pairs carry a push at all.
24///
25/// The two travel together because they are read together and must come
26/// from one moment: a values map from before a push read beside a pair set
27/// from after it would call a key permanently missing that the push had
28/// just supplied.
29///
30/// An empty push is why the pair set is not derivable from the values. A
31/// dog saying "I have nothing for staging" registers the pair and holds no
32/// keys, which is a different answer from a dog that has not pushed.
33///
34/// Debug does not leak a value: it prints two counts.
35#[derive(Default, Clone)]
36pub struct ProviderCache {
37 /// Every namespace's values.
38 ///
39 /// Private where [`Self::pushed`] is not. A [`NamespaceValues`] is a
40 /// plain nested map, so printing one prints every value in it, and a
41 /// public field is exactly what a caller reaches for after reading
42 /// that this type redacts (IR-41). Read it through
43 /// [`Self::namespace`], [`Self::namespaces`] or [`Self::into_parts`].
44 values: NamespaceValues,
45 /// Every `(namespace, environment)` pair a push has landed for.
46 ///
47 /// Names, never values, which is why this half stays public: the
48 /// shepherd's boot log prints it as it stands.
49 pub pushed: PushedPairs,
50}
51
52impl ProviderCache {
53 /// A cache holding `values`, pushed as the pairs in `pushed`.
54 #[must_use]
55 pub fn new(values: NamespaceValues, pushed: PushedPairs) -> Self {
56 Self { values, pushed }
57 }
58
59 /// One namespace's values, or nothing when no push has carried that
60 /// namespace.
61 ///
62 /// Present and empty is a dog that pushed no keys. Whether that dog
63 /// has spoken for a given environment is [`Self::pushed`]'s answer,
64 /// not this one's.
65 #[must_use]
66 pub fn namespace(&self, name: &str) -> Option<&NamespaceTable> {
67 self.values.get(name)
68 }
69
70 /// Every namespace and its values, in name order.
71 pub fn namespaces(&self) -> impl Iterator<Item = (&String, &NamespaceTable)> {
72 self.values.iter()
73 }
74
75 /// How many namespaces a push has carried.
76 #[must_use]
77 pub fn namespace_count(&self) -> usize {
78 self.values.len()
79 }
80
81 /// Both halves, moved out, for a caller that owns them afterwards.
82 #[must_use]
83 pub fn into_parts(self) -> (NamespaceValues, PushedPairs) {
84 (self.values, self.pushed)
85 }
86}
87
88/// Redacted (IR-41): `values` holds provider values in the clear.
89impl fmt::Debug for ProviderCache {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.debug_struct("ProviderCache")
92 .field("namespaces", &self.values.len())
93 .field("pushed", &self.pushed.len())
94 .finish()
95 }
96}
97
98/// The on-disk shape of `secrets-cache.json`, mirrored from
99/// `shep-daemon`'s own private writer so a reader on this side of the
100/// crate boundary can stay in step with it without importing a published
101/// binary crate's internals.
102#[derive(Default, Deserialize)]
103pub(super) struct ProviderCacheFile {
104 pub(super) version: u32,
105 #[serde(default)]
106 pub(super) namespaces: NamespaceValues,
107 #[serde(default)]
108 pub(super) pushed: PushedPairs,
109}
110
111/// Redacted (IR-41), matching `shep-daemon`'s own `CacheFile`: `namespaces`
112/// holds provider values in the clear.
113impl fmt::Debug for ProviderCacheFile {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.debug_struct("ProviderCacheFile")
116 .field("version", &self.version)
117 .field("namespaces", &self.namespaces.len())
118 .field("pushed", &self.pushed.len())
119 .finish()
120 }
121}
122
123/// The `secrets-cache.json` format version this build reads and writes.
124///
125/// One constant for both sides: shep-daemon writes the file and stamps it
126/// with this, and [`provider_cache_on_disk`] refuses anything else. Two
127/// literals of the same value would let a bump on one side turn every read
128/// on the other into an empty cache, with nothing to say why.
129pub const PROVIDER_CACHE_VERSION: u32 = 2;
130
131/// The provider cache as `secrets-cache.json` currently holds it on disk,
132/// or nothing when the file is missing, will not parse, or is a version
133/// this build does not understand.
134///
135/// Best-effort, more so than [`all`](crate::secrets::all): a namespace whose provider pushed
136/// with `persist = false` never reaches this file at all, so a caller here
137/// can under-report `MissingNamespace` for a pair the running shepherd
138/// currently holds in memory. A caller that needs the shepherd's live
139/// answer has to ask it directly rather than read this file.
140#[must_use]
141pub fn provider_cache_on_disk(path: &Path) -> ProviderCache {
142 let Ok(raw) = std::fs::read_to_string(path) else {
143 return ProviderCache::default();
144 };
145 match serde_json::from_str::<ProviderCacheFile>(&raw) {
146 Ok(file) if file.version == PROVIDER_CACHE_VERSION => {
147 ProviderCache::new(file.namespaces, file.pushed)
148 }
149 _ => ProviderCache::default(),
150 }
151}
152
153#[cfg(test)]
154mod tests {
155
156 use std::collections::BTreeSet;
157
158 use super::super::testing::*;
159 use super::*;
160
161 #[test]
162 fn provider_cache_on_disk_reads_a_real_cache_file() {
163 let dir = tempfile::tempdir().unwrap();
164 let path = dir.path().join("secrets-cache.json");
165 std::fs::write(
166 &path,
167 r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
168 )
169 .unwrap();
170 let cache = provider_cache_on_disk(&path);
171 assert_eq!(
172 cache.namespace("vercel").unwrap()["API_KEY"]["production"],
173 "sk_live"
174 );
175 assert_eq!(
176 cache.pushed["vercel"],
177 BTreeSet::from(["production".to_string()])
178 );
179 }
180
181 #[test]
182 fn provider_cache_on_disk_is_empty_for_a_missing_or_broken_file() {
183 let dir = tempfile::tempdir().unwrap();
184 assert_eq!(
185 provider_cache_on_disk(&dir.path().join("absent.json")).namespace_count(),
186 0
187 );
188
189 let broken = dir.path().join("broken.json");
190 std::fs::write(&broken, "not json").unwrap();
191 assert_eq!(provider_cache_on_disk(&broken).namespace_count(), 0);
192
193 let future = dir.path().join("future.json");
194 std::fs::write(&future, r#"{"version":999,"namespaces":{}}"#).unwrap();
195 assert_eq!(provider_cache_on_disk(&future).namespace_count(), 0);
196 }
197
198 /// IR-41 for the type the two halves travel in together.
199 #[test]
200 fn a_provider_cache_debug_never_prints_a_value() {
201 let cache = vercel_production();
202 assert_eq!(
203 format!("{cache:?}"),
204 "ProviderCache { namespaces: 1, pushed: 1 }"
205 );
206 }
207}