1#[cfg(all(test, feature = "json"))]
38use std::collections::BTreeMap;
39use std::fmt;
40use std::collections::hash_map::DefaultHasher;
44use std::hash::{Hash, Hasher};
45use std::path::Path;
46
47use figment::value::{Dict, Value};
48
49use crate::error::{Error, ErrorKind, Origin};
50use crate::snapshot::Snapshot;
51use crate::source::Format;
52
53const CACHED: &str = "cached";
55
56const FINGERPRINT: &str = "fingerprint";
58
59const KEYS: &str = "keys";
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82#[non_exhaustive]
83pub enum CacheMode {
84 #[default]
90 Full,
91 Redacted,
97 Fingerprint,
103}
104
105impl fmt::Display for CacheMode {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(match self {
108 Self::Full => "full",
109 Self::Redacted => "redacted",
110 Self::Fingerprint => "fingerprint",
111 })
112 }
113}
114
115impl CacheMode {
116 pub(crate) fn parse(name: &str) -> Option<Self> {
119 match name {
120 "full" => Some(Self::Full),
121 "redacted" => Some(Self::Redacted),
122 "fingerprint" => Some(Self::Fingerprint),
123 _ => None,
124 }
125 }
126
127 #[must_use]
129 pub fn recovers(self) -> bool {
130 !matches!(self, Self::Fingerprint)
131 }
132}
133
134#[derive(Debug)]
136pub enum Recovery {
137 Usable(Snapshot),
139 Drift(Vec<String>),
143 Absent,
145}
146
147pub(crate) fn write(
156 snapshot: &Snapshot,
157 path: &Path,
158 mode: CacheMode,
159 secrets: &[&str],
160) -> Result<(), Error> {
161 let format = format_of(path)?;
162
163 let document = match mode {
164 CacheMode::Full => snapshot.values().clone(),
165 CacheMode::Redacted => without(snapshot.values(), secrets),
166 CacheMode::Fingerprint => fingerprint_document(snapshot),
167 };
168
169 crate::write::save_dict(&document, path, format, CACHED)
170}
171
172pub(crate) fn read(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
179 let format = format_of(path)?;
180
181 let text = match std::fs::read_to_string(path) {
182 Ok(text) => text,
183 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
184 Err(error) => {
185 return Err(Error::new(ErrorKind::Io, error.to_string())
186 .with_origin(Origin::File(path.to_owned())))
187 }
188 };
189
190 let sources = [crate::Source::inline(&text, format)];
191 let cached = crate::loader::snapshot(&crate::LoadSpec::new(CACHED, &sources))?;
192
193 if !cached.contains(FINGERPRINT) {
194 return Ok(Recovery::Usable(cached));
195 }
196
197 Ok(Recovery::Drift(drift(&cached, current)))
198}
199
200fn drift(cached: &Snapshot, current: Option<&Snapshot>) -> Vec<String> {
202 let Some(current) = current else {
203 return Vec::new();
204 };
205
206 let before: Vec<String> = cached.get(KEYS).unwrap_or_default();
207 let after = current.leaf_paths();
208
209 let mut moved: Vec<String> = before
210 .iter()
211 .filter(|key| !after.contains(key))
212 .map(|key| format!("{key} is gone"))
213 .chain(
214 after
215 .iter()
216 .filter(|key| !before.contains(key))
217 .map(|key| format!("{key} is new")),
218 )
219 .collect();
220
221 moved.sort();
222 moved
223}
224
225fn without(values: &Dict, secrets: &[&str]) -> Dict {
227 values
228 .iter()
229 .filter(|(key, _)| !secrets.contains(&key.as_str()))
230 .map(|(key, value)| (key.clone(), value.clone()))
231 .collect()
232}
233
234fn fingerprint_document(snapshot: &Snapshot) -> Dict {
236 let keys = snapshot.leaf_paths();
237
238 let mut hasher = DefaultHasher::new();
239
240 format!("{:?}", snapshot.values()).hash(&mut hasher);
244
245 let mut document = Dict::new();
246 document.insert(
247 FINGERPRINT.to_owned(),
248 Value::from(format!("{:016x}", hasher.finish())),
249 );
250 document.insert(
251 KEYS.to_owned(),
252 Value::from(keys.into_iter().map(Value::from).collect::<Vec<_>>()),
253 );
254
255 document
256}
257
258fn format_of(path: &Path) -> Result<Format, Error> {
259 if path.extension().is_some_and(|extension| extension == "age") {
263 return Err(Error::new(
264 ErrorKind::Backend,
265 format!(
266 "{} ends in `.age`, but the last-known-good cache is written in plaintext; give the cache an unencrypted name",
267 path.display()
268 ),
269 ));
270 }
271
272 path.extension()
273 .and_then(|extension| extension.to_str())
274 .and_then(Format::from_extension)
275 .ok_or_else(|| Error::unsupported(path))
276}
277
278#[cfg(all(test, feature = "json"))]
280fn dict_of(entries: &[(&str, Value)]) -> BTreeMap<String, Value> {
281 entries
282 .iter()
283 .map(|(key, value)| ((*key).to_owned(), value.clone()))
284 .collect()
285}
286
287#[cfg(all(test, feature = "json"))]
288mod tests {
289 use super::*;
290
291 fn scratch(test: &str) -> std::path::PathBuf {
292 let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
293
294 let _ = std::fs::remove_dir_all(&directory);
295 std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
296
297 directory.join("cache.json")
298 }
299
300 fn snapshot() -> Snapshot {
301 Snapshot::new(dict_of(&[
302 ("host", "localhost".into()),
303 ("password", "hunter2".into()),
304 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
305 ]))
306 }
307
308 #[test]
309 fn full_keeps_everything_and_recovers() {
310 let path = scratch("full");
311
312 write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
313
314 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
315 panic!("a full cache must be usable");
316 };
317
318 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
319 assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
320 assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
321 }
322
323 #[test]
324 fn redacted_drops_the_marked_fields_and_nothing_else() {
325 let path = scratch("redacted");
326
327 write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
328
329 let written = std::fs::read_to_string(&path).unwrap();
330 assert!(!written.contains("hunter2"), "{written}");
331
332 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
333 panic!("a redacted cache is still usable");
334 };
335
336 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
337 assert!(
338 !recovered.contains("password"),
339 "the secret must not have survived"
340 );
341 }
342
343 #[test]
344 fn fingerprint_writes_no_value_at_all() {
345 let path = scratch("fingerprint");
346
347 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
348
349 let written = std::fs::read_to_string(&path).unwrap();
350
351 assert!(!written.contains("hunter2"), "{written}");
352 assert!(!written.contains("localhost"), "{written}");
353 assert!(written.contains("host"), "{written}");
355 }
356
357 #[test]
358 fn fingerprint_cannot_recover_but_reports_what_moved() {
359 let path = scratch("drift");
360
361 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
362
363 let now = Snapshot::new(dict_of(&[
364 ("host", "localhost".into()),
365 ("hsot", "typo".into()),
366 ]));
367
368 let Recovery::Drift(moved) = read(&path, Some(&now)).unwrap() else {
369 panic!("a fingerprint cache cannot be usable");
370 };
371
372 assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
373 assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
374 }
375
376 #[test]
377 fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
378 let error = write(
379 &snapshot(),
380 Path::new("cache.json.age"),
381 CacheMode::Full,
382 &[],
383 )
384 .unwrap_err();
385
386 assert!(error.to_string().contains("plaintext"), "{error}");
387 }
388
389 #[test]
390 fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
391 let path = scratch("absent").with_file_name("nothing.json");
392
393 assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
394 }
395
396 #[test]
397 fn only_fingerprint_refuses_to_recover() {
398 assert!(CacheMode::Full.recovers());
399 assert!(CacheMode::Redacted.recovers());
400 assert!(!CacheMode::Fingerprint.recovers());
401 }
402}