#[cfg(all(test, feature = "json"))]
use std::collections::BTreeMap;
use std::fmt;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use figment::value::{Dict, Value};
use crate::error::{Error, ErrorKind, Origin};
use crate::snapshot::Snapshot;
use crate::source::Format;
const CACHED: &str = "cached";
const FINGERPRINT: &str = "fingerprint";
const KEYS: &str = "keys";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CacheMode {
#[default]
Full,
Redacted,
Fingerprint,
}
impl fmt::Display for CacheMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Full => "full",
Self::Redacted => "redacted",
Self::Fingerprint => "fingerprint",
})
}
}
impl CacheMode {
pub(crate) fn parse(name: &str) -> Option<Self> {
match name {
"full" => Some(Self::Full),
"redacted" => Some(Self::Redacted),
"fingerprint" => Some(Self::Fingerprint),
_ => None,
}
}
#[must_use]
pub fn recovers(self) -> bool {
!matches!(self, Self::Fingerprint)
}
}
#[derive(Debug)]
pub enum Recovery {
Usable(Snapshot),
Drift(Vec<String>),
Absent,
}
pub(crate) fn write(
snapshot: &Snapshot,
path: &Path,
mode: CacheMode,
secrets: &[&str],
) -> Result<(), Error> {
let format = format_of(path)?;
let document = match mode {
CacheMode::Full => snapshot.values().clone(),
CacheMode::Redacted => without(snapshot.values(), secrets),
CacheMode::Fingerprint => fingerprint_document(snapshot),
};
crate::write::save_dict(&document, path, format, CACHED)
}
pub(crate) fn read(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
let format = format_of(path)?;
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
Err(error) => {
return Err(Error::new(ErrorKind::Io, error.to_string())
.with_origin(Origin::File(path.to_owned())))
}
};
let sources = [crate::Source::inline(&text, format)];
let cached = crate::loader::snapshot(&crate::LoadSpec::new(CACHED, &sources))?;
if !cached.contains(FINGERPRINT) {
return Ok(Recovery::Usable(cached));
}
Ok(Recovery::Drift(drift(&cached, current)))
}
fn drift(cached: &Snapshot, current: Option<&Snapshot>) -> Vec<String> {
let Some(current) = current else {
return Vec::new();
};
let before: Vec<String> = cached.get(KEYS).unwrap_or_default();
let after = current.leaf_paths();
let mut moved: Vec<String> = before
.iter()
.filter(|key| !after.contains(key))
.map(|key| format!("{key} is gone"))
.chain(
after
.iter()
.filter(|key| !before.contains(key))
.map(|key| format!("{key} is new")),
)
.collect();
moved.sort();
moved
}
fn without(values: &Dict, secrets: &[&str]) -> Dict {
values
.iter()
.filter(|(key, _)| !secrets.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn fingerprint_document(snapshot: &Snapshot) -> Dict {
let keys = snapshot.leaf_paths();
let mut hasher = DefaultHasher::new();
format!("{:?}", snapshot.values()).hash(&mut hasher);
let mut document = Dict::new();
document.insert(
FINGERPRINT.to_owned(),
Value::from(format!("{:016x}", hasher.finish())),
);
document.insert(
KEYS.to_owned(),
Value::from(keys.into_iter().map(Value::from).collect::<Vec<_>>()),
);
document
}
fn format_of(path: &Path) -> Result<Format, Error> {
if path.extension().is_some_and(|extension| extension == "age") {
return Err(Error::new(
ErrorKind::Backend,
format!(
"{} ends in `.age`, but the last-known-good cache is written in plaintext; give the cache an unencrypted name",
path.display()
),
));
}
path.extension()
.and_then(|extension| extension.to_str())
.and_then(Format::from_extension)
.ok_or_else(|| Error::unsupported(path))
}
#[cfg(all(test, feature = "json"))]
fn dict_of(entries: &[(&str, Value)]) -> BTreeMap<String, Value> {
entries
.iter()
.map(|(key, value)| ((*key).to_owned(), value.clone()))
.collect()
}
#[cfg(all(test, feature = "json"))]
mod tests {
use super::*;
fn scratch(test: &str) -> std::path::PathBuf {
let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
directory.join("cache.json")
}
fn snapshot() -> Snapshot {
Snapshot::new(dict_of(&[
("host", "localhost".into()),
("password", "hunter2".into()),
("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
]))
}
#[test]
fn full_keeps_everything_and_recovers() {
let path = scratch("full");
write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
panic!("a full cache must be usable");
};
assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
}
#[test]
fn redacted_drops_the_marked_fields_and_nothing_else() {
let path = scratch("redacted");
write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(!written.contains("hunter2"), "{written}");
let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
panic!("a redacted cache is still usable");
};
assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
assert!(
!recovered.contains("password"),
"the secret must not have survived"
);
}
#[test]
fn fingerprint_writes_no_value_at_all() {
let path = scratch("fingerprint");
write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(!written.contains("hunter2"), "{written}");
assert!(!written.contains("localhost"), "{written}");
assert!(written.contains("host"), "{written}");
}
#[test]
fn fingerprint_cannot_recover_but_reports_what_moved() {
let path = scratch("drift");
write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
let now = Snapshot::new(dict_of(&[
("host", "localhost".into()),
("hsot", "typo".into()),
]));
let Recovery::Drift(moved) = read(&path, Some(&now)).unwrap() else {
panic!("a fingerprint cache cannot be usable");
};
assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
}
#[test]
fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
let error = write(
&snapshot(),
Path::new("cache.json.age"),
CacheMode::Full,
&[],
)
.unwrap_err();
assert!(error.to_string().contains("plaintext"), "{error}");
}
#[test]
fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
let path = scratch("absent").with_file_name("nothing.json");
assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
}
#[test]
fn only_fingerprint_refuses_to_recover() {
assert!(CacheMode::Full.recovers());
assert!(CacheMode::Redacted.recovers());
assert!(!CacheMode::Fingerprint.recovers());
}
}