use serde::Serialize;
use sha2::{Digest, Sha256};
pub const FACT_DIGEST_VERSION: &str = "fact-v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FactContent {
pub os: String,
pub arch: String,
pub process_executables: Vec<String>,
pub packages: Vec<String>,
pub listen_ports: Vec<String>,
}
impl FactContent {
pub fn new(
os: impl Into<String>,
arch: impl Into<String>,
process_executables: Vec<String>,
packages: Vec<String>,
listen_ports: Vec<String>,
) -> Self {
Self {
os: os.into(),
arch: arch.into(),
process_executables,
packages,
listen_ports,
}
}
pub fn content_digest(&self) -> String {
let canonical = CanonicalContent {
os: &self.os,
arch: &self.arch,
process_executables: normalized(&self.process_executables),
packages: normalized(&self.packages),
listen_ports: normalized(&self.listen_ports),
};
let bytes =
serde_json::to_vec(&canonical).expect("fact content serialization is infallible");
let hash = Sha256::digest(&bytes);
let hex: String = hash.iter().map(|byte| format!("{byte:02x}")).collect();
format!("{FACT_DIGEST_VERSION}:sha256:{hex}")
}
}
#[derive(Serialize)]
struct CanonicalContent<'a> {
os: &'a str,
arch: &'a str,
process_executables: Vec<String>,
packages: Vec<String>,
listen_ports: Vec<String>,
}
fn normalized(values: &[String]) -> Vec<String> {
let mut sorted: Vec<String> = values.to_vec();
sorted.sort();
sorted.dedup();
sorted
}
#[cfg(test)]
mod tests {
use super::*;
fn content(executables: &[&str]) -> FactContent {
FactContent::new(
"macos",
"arm64",
executables.iter().map(|value| value.to_string()).collect(),
Vec::new(),
Vec::new(),
)
}
#[test]
fn digest_is_stable_and_versioned() {
let digest = content(&["/usr/bin/xcodebuild"]).content_digest();
assert!(digest.starts_with("fact-v1:sha256:"), "{digest}");
assert_eq!(digest.len(), "fact-v1:sha256:".len() + 64);
assert_eq!(digest, content(&["/usr/bin/xcodebuild"]).content_digest());
}
#[test]
fn digest_normalizes_order_and_duplicates() {
let base = content(&["b", "a"]).content_digest();
assert_eq!(base, content(&["a", "b"]).content_digest());
assert_eq!(base, content(&["b", "a", "a"]).content_digest());
}
#[test]
fn digest_changes_with_content() {
let base = content(&["a"]).content_digest();
assert_ne!(base, content(&["a", "b"]).content_digest());
let mut other = content(&["a"]);
other.os = "linux".to_string();
assert_ne!(base, other.content_digest());
}
#[test]
fn revision_and_process_count_are_outside_the_digest() {
let content = content(&["a"]);
let before = content.content_digest();
let recomputed = FactContent::new(
content.os.clone(),
content.arch.clone(),
content.process_executables.clone(),
content.packages.clone(),
content.listen_ports.clone(),
);
assert_eq!(before, recomputed.content_digest());
}
#[test]
fn packages_and_listen_ports_are_part_of_the_digest() {
let base = FactContent::new("linux", "x86_64", Vec::new(), Vec::new(), Vec::new())
.content_digest();
let with_package = FactContent::new(
"linux",
"x86_64",
Vec::new(),
vec!["nginx".to_string()],
Vec::new(),
)
.content_digest();
let with_port = FactContent::new(
"linux",
"x86_64",
Vec::new(),
Vec::new(),
vec!["443".to_string()],
)
.content_digest();
assert_ne!(base, with_package);
assert_ne!(base, with_port);
assert_ne!(with_package, with_port);
}
#[test]
fn an_empty_content_digest_is_stable_and_well_formed() {
let digest = FactContent::new("", "", Vec::new(), Vec::new(), Vec::new()).content_digest();
assert!(digest.starts_with("fact-v1:sha256:"));
assert_eq!(digest.len(), "fact-v1:sha256:".len() + 64);
assert_eq!(
digest,
FactContent::new("", "", Vec::new(), Vec::new(), Vec::new()).content_digest()
);
}
}