use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
use crate::value::Value;
type Document = BTreeMap<String, Value>;
const MASKED: &str = "\u{0}dynamic-config:secret\u{0}";
pub(crate) fn of(values: &Document, secrets: &[&str]) -> String {
let digest = match secrets {
[] => digest_of(values),
secrets => {
let mut masked = values.clone();
for secret in secrets {
mask_path(&mut masked, secret);
}
digest_of(&masked)
}
};
let digest = digest.as_ref();
let mut rendered = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write;
let _ = write!(rendered, "{byte:02x}");
}
rendered
}
fn digest_of(values: &Document) -> impl AsRef<[u8]> {
let mut hasher = Sha256::new();
write_table(&mut hasher, values);
hasher.finalize()
}
pub(crate) fn rendered(values: &Document, secrets: &[&str]) -> String {
format!("sha256:{}", of(values, secrets))
}
fn mask_path(table: &mut Document, path: &str) {
match path.split_once('.') {
None => {
if let Some(slot) = table.get_mut(path) {
*slot = Value::Null;
table.insert(format!("{MASKED}{path}"), Value::Bool(true));
}
}
Some((head, rest)) => {
if let Some(Value::Table(nested)) = table.get_mut(head) {
mask_path(nested, rest);
}
}
}
}
fn write_table(hasher: &mut Sha256, table: &Document) {
hasher.update(*b"T");
hasher.update(u64::try_from(table.len()).unwrap_or(u64::MAX).to_be_bytes());
for (key, value) in table {
write_bytes(hasher, key.as_bytes());
write_value(hasher, value);
}
}
fn write_value(hasher: &mut Sha256, value: &Value) {
match value {
Value::Table(table) => write_table(hasher, table),
Value::Array(values) => {
hasher.update(*b"A");
hasher.update(
u64::try_from(values.len())
.unwrap_or(u64::MAX)
.to_be_bytes(),
);
for value in values {
write_value(hasher, value);
}
}
Value::String(text) => {
hasher.update(*b"S");
write_bytes(hasher, text.as_bytes());
}
Value::Integer(number) => {
hasher.update(*b"I");
hasher.update(number.to_be_bytes());
}
Value::Float(number) => {
hasher.update(*b"F");
hasher.update(number.to_bits().to_be_bytes());
}
Value::Bool(flag) => {
hasher.update([b'B', u8::from(*flag)]);
}
Value::Null => {
hasher.update(*b"N");
}
}
}
fn write_bytes(hasher: &mut Sha256, bytes: &[u8]) {
hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
hasher.update(bytes);
}
#[cfg(test)]
mod tests {
use super::*;
fn table(pairs: &[(&str, Value)]) -> Document {
pairs
.iter()
.map(|(key, value)| ((*key).to_owned(), value.clone()))
.collect()
}
#[test]
fn the_same_document_hashes_the_same() {
let one = table(&[("host", Value::String("db".to_owned()))]);
let two = table(&[("host", Value::String("db".to_owned()))]);
assert_eq!(of(&one, &[]), of(&two, &[]));
}
#[test]
fn a_changed_value_moves_it() {
let before = table(&[("port", Value::Integer(80))]);
let after = table(&[("port", Value::Integer(443))]);
assert_ne!(of(&before, &[]), of(&after, &[]));
}
#[test]
fn a_rotated_secret_does_not_move_it() {
let before = table(&[
("host", Value::String("db".to_owned())),
("password", Value::String("first".to_owned())),
]);
let after = table(&[
("host", Value::String("db".to_owned())),
("password", Value::String("second".to_owned())),
]);
assert_eq!(
of(&before, &["password"]),
of(&after, &["password"]),
"a fingerprint that moved when a password rotated would be an \
oracle for the password"
);
}
#[test]
fn a_secret_appearing_does_move_it() {
let without = table(&[("host", Value::String("db".to_owned()))]);
let with = table(&[
("host", Value::String("db".to_owned())),
("password", Value::String("first".to_owned())),
]);
assert_ne!(
of(&without, &["password"]),
of(&with, &["password"]),
"gaining a secret is a change in shape, not a change in value"
);
}
#[test]
fn nesting_cannot_be_flattened_into_a_collision() {
let nested = table(&[("a", Value::Table(table(&[("b", Value::Integer(1))])))]);
let flat = table(&[("a.b", Value::Integer(1))]);
assert_ne!(of(&nested, &[]), of(&flat, &[]));
}
#[test]
fn keys_of_different_lengths_cannot_collide() {
let one = table(&[("ab", Value::String("c".to_owned()))]);
let two = table(&[("a", Value::String("bc".to_owned()))]);
assert_ne!(of(&one, &[]), of(&two, &[]));
}
#[test]
fn a_float_and_an_integer_are_different_values() {
let integer = table(&[("n", Value::Integer(1))]);
let float = table(&[("n", Value::Float(1.0))]);
assert_ne!(of(&integer, &[]), of(&float, &[]));
}
#[test]
fn masking_is_by_position_and_not_by_content() {
let impostor = table(&[("password", Value::String(MASKED.to_owned()))]);
let genuine = table(&[("password", Value::String("hunter2".to_owned()))]);
assert_ne!(of(&impostor, &[]), of(&genuine, &["password"]));
}
#[test]
fn an_absent_secret_and_a_null_one_are_different_shapes() {
let absent = table(&[("host", Value::String("db".to_owned()))]);
let null = table(&[
("host", Value::String("db".to_owned())),
("password", Value::Null),
]);
assert_ne!(of(&absent, &["password"]), of(&null, &["password"]));
}
}