use std::borrow::Cow;
use std::fmt;
use sha2::{Digest, Sha256};
use crate::telemetry::config::TelemetryConfig;
pub(crate) fn opaque_id(raw: &str, secret: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(secret);
hasher.update(raw.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(&mut out, "{:02x}", byte);
}
out
}
#[derive(Clone)]
pub(crate) struct Scrubber {
enabled: bool,
secret: [u8; 16],
}
impl fmt::Debug for Scrubber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Scrubber")
.field("enabled", &self.enabled)
.field("secret", &"<redacted>")
.finish()
}
}
impl Scrubber {
#[allow(dead_code)]
pub fn new(enabled: bool, secret: [u8; 16]) -> Self {
Self { enabled, secret }
}
pub fn from_config(config: &TelemetryConfig, fallback_secret: [u8; 16]) -> Self {
Self {
enabled: config.scrub_ids(),
secret: config.scrub_secret().unwrap_or(fallback_secret),
}
}
#[allow(dead_code)]
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn hash_id<'a>(&self, raw: &'a str) -> Cow<'a, str> {
if self.enabled {
Cow::Owned(opaque_id(raw, &self.secret))
} else {
Cow::Borrowed(raw)
}
}
pub fn hash_always(&self, raw: &str) -> String {
opaque_id(raw, &self.secret)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opaque_id_is_stable_for_same_input() {
let secret = b"secret";
let first = opaque_id("peer:alice", secret);
let second = opaque_id("peer:alice", secret);
assert_eq!(first, second);
assert_eq!(first.len(), 32);
}
#[test]
fn opaque_id_changes_for_different_secret() {
let first = opaque_id("peer:alice", b"secret-a");
let second = opaque_id("peer:alice", b"secret-b");
assert_ne!(first, second);
}
#[test]
fn opaque_id_handles_empty_input() {
let secret = b"secret";
let out = opaque_id("", secret);
assert_eq!(out.len(), 32);
assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn disabled_scrubber_borrows_raw() {
let scrubber = Scrubber::new(false, [0; 16]);
let out = scrubber.hash_id("peer-a");
assert_eq!(out, "peer-a");
assert!(matches!(out, Cow::Borrowed(_)));
assert!(!scrubber.is_enabled());
}
#[test]
fn disabled_scrubber_borrows_empty_input() {
let scrubber = Scrubber::new(false, [0; 16]);
let out = scrubber.hash_id("");
assert_eq!(out, "");
assert!(matches!(out, Cow::Borrowed(_)));
}
#[test]
fn enabled_scrubber_handles_empty_input() {
let scrubber = Scrubber::new(true, [1; 16]);
let out = scrubber.hash_id("");
assert_eq!(out.len(), 32);
assert!(matches!(out, Cow::Owned(_)));
}
#[test]
fn enabled_scrubber_returns_stable_hash() {
let scrubber = Scrubber::new(true, [1; 16]);
let first = scrubber.hash_id("peer-a").into_owned();
let second = scrubber.hash_id("peer-a").into_owned();
assert_eq!(first, second);
assert_eq!(first.len(), 32);
assert_ne!(first, "peer-a");
}
#[test]
fn different_secrets_produce_different_hashes() {
let a = Scrubber::new(true, [1; 16]);
let b = Scrubber::new(true, [2; 16]);
assert_ne!(a.hash_id("peer-a"), b.hash_id("peer-a"));
}
#[test]
fn from_config_prefers_config_secret() {
let config = TelemetryConfig::default().with_scrub_secret(Some([9; 16]));
let fallback = [0; 16];
let scrubber = Scrubber::from_config(&config, fallback);
assert!(scrubber.is_enabled());
let expected = Scrubber::new(true, [9; 16]).hash_id("peer-a").into_owned();
assert_eq!(scrubber.hash_id("peer-a"), expected);
}
#[test]
fn from_config_uses_fallback_when_secret_absent() {
let config = TelemetryConfig::default();
let fallback = [5; 16];
let scrubber = Scrubber::from_config(&config, fallback);
let expected = Scrubber::new(true, fallback).hash_id("peer-a").into_owned();
assert_eq!(scrubber.hash_id("peer-a"), expected);
}
#[test]
fn from_config_with_scrub_ids_disabled_passes_raw_through_hash_id() {
let config = TelemetryConfig::default().with_scrub_ids(false);
let scrubber = Scrubber::from_config(&config, [3; 16]);
assert!(!scrubber.is_enabled());
let leaf = scrubber.hash_id("peer-a");
assert_eq!(leaf, "peer-a");
assert!(matches!(leaf, Cow::Borrowed(_)));
let derived = scrubber.hash_always("peer-a|group-b");
assert_eq!(derived.len(), 32);
assert!(derived.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(derived, "peer-a|group-b");
}
#[test]
fn hash_always_ignores_enabled_flag() {
let enabled = Scrubber::new(true, [1; 16]);
let disabled = Scrubber::new(false, [1; 16]);
assert_eq!(
enabled.hash_always("peer-a"),
disabled.hash_always("peer-a")
);
let out = disabled.hash_always("peer-a");
assert_eq!(out.len(), 32);
assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(out, "peer-a");
}
#[test]
fn hash_always_matches_hash_id_when_enabled() {
let scrubber = Scrubber::new(true, [9; 16]);
assert_eq!(
scrubber.hash_always("peer-a"),
scrubber.hash_id("peer-a").into_owned(),
);
}
#[test]
fn debug_redacts_secret() {
let scrubber = Scrubber::new(true, [0xAB; 16]);
let rendered = format!("{scrubber:?}");
assert!(
rendered.contains("<redacted>"),
"expected redaction marker, got {rendered}"
);
assert!(
!rendered.contains("ab, ab") && !rendered.contains("171, 171"),
"secret bytes leaked into Debug output: {rendered}"
);
}
}