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