use serde_json::Value;
use crate::config::DEFAULT_METADATA_REDACT_KEYS;
#[derive(Clone, Debug)]
pub struct MetadataRedactor {
rules: Vec<KeyRule>,
}
impl Default for MetadataRedactor {
fn default() -> Self {
Self::from_csv(DEFAULT_METADATA_REDACT_KEYS)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum KeyRule {
Exact(String),
Prefix(String),
}
impl MetadataRedactor {
pub fn from_csv(patterns: &str) -> Self {
let mut rules = Vec::new();
for pattern in patterns.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let normalized = pattern.to_ascii_lowercase();
let rule = normalized.strip_suffix('*').map_or_else(
|| KeyRule::Exact(normalized.clone()),
|prefix| KeyRule::Prefix(prefix.to_string()),
);
if !rules.contains(&rule) {
rules.push(rule);
}
}
Self { rules }
}
pub fn redact(&self, metadata: &mut Value) {
if self.rules.is_empty() {
return;
}
self.redact_value(metadata);
}
fn redact_value(&self, metadata: &mut Value) {
match metadata {
Value::Object(object) => {
object.retain(|key, value| {
if self.denies(key) {
false
} else {
self.redact_value(value);
true
}
});
}
Value::Array(values) => {
for value in values {
self.redact_value(value);
}
}
_ => {}
}
}
fn denies(&self, key: &str) -> bool {
let key = key.to_ascii_lowercase();
self.rules.iter().any(|rule| match rule {
KeyRule::Exact(expected) => key == *expected,
KeyRule::Prefix(prefix) => key.starts_with(prefix),
})
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::MetadataRedactor;
#[test]
fn strips_exact_and_prefix_matches_recursively() {
let redactor = MetadataRedactor::from_csv(
"maintainer_email, author_email, contact_*, maintainer_email",
);
let mut metadata = json!({
"maintainer_email": "maintainer@example.org",
"Author_Email": "author@example.org",
"title": "Public title",
"nested": {
"contact_name": "A person",
"notes": "Public notes",
"items": [{
"contact_endpoint": "https://internal.example.org",
"public_url": "https://data.example.org"
}]
}
});
redactor.redact(&mut metadata);
assert!(metadata.get("maintainer_email").is_none());
assert!(metadata.get("Author_Email").is_none());
assert_eq!(metadata["title"], "Public title");
assert!(metadata["nested"].get("contact_name").is_none());
assert_eq!(metadata["nested"]["notes"], "Public notes");
assert!(
metadata["nested"]["items"][0]
.get("contact_endpoint")
.is_none()
);
assert_eq!(
metadata["nested"]["items"][0]["public_url"],
"https://data.example.org"
);
}
#[test]
fn custom_rules_replace_the_default_policy() {
let redactor = MetadataRedactor::from_csv("secret,private_*");
let mut metadata = json!({
"secret": "remove",
"private_token": "remove",
"contact_email": "preserve because the default was replaced"
});
redactor.redact(&mut metadata);
assert!(metadata.get("secret").is_none());
assert!(metadata.get("private_token").is_none());
assert!(metadata.get("contact_email").is_some());
}
#[test]
fn default_uses_the_public_api_policy() {
let redactor = MetadataRedactor::default();
let mut metadata = json!({
"maintainer_email": "remove",
"contact_phone": "remove",
"title": "preserve"
});
redactor.redact(&mut metadata);
assert!(metadata.get("maintainer_email").is_none());
assert!(metadata.get("contact_phone").is_none());
assert!(metadata.get("title").is_some());
}
#[test]
fn empty_policy_leaves_metadata_unchanged() {
let redactor = MetadataRedactor::from_csv("");
let mut metadata = json!({
"maintainer_email": "preserve",
"nested": [{"contact_phone": "preserve"}]
});
let original = metadata.clone();
redactor.redact(&mut metadata);
assert_eq!(metadata, original);
}
}