use std::fmt;
use serde::{Deserialize, Serialize};
const REDACTED: &str = "***REDACTED***";
#[derive(Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct Secret(String);
impl Serialize for Secret {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(REDACTED)
}
}
impl Secret {
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED)
}
}
impl fmt::Display for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED)
}
}
impl From<String> for Secret {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for Secret {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_and_display_both_redact() {
let secret = Secret::from("token-wxyz");
assert_eq!(format!("{secret:?}"), REDACTED);
assert_eq!(format!("{secret}"), REDACTED);
}
#[test]
fn a_struct_holding_one_does_not_leak_it_through_a_derived_debug() {
#[derive(Debug)]
#[allow(dead_code)]
struct RequestPlan {
url: &'static str,
token: Secret,
}
let rendered = format!(
"{:?}",
RequestPlan {
url: "https://example.invalid/db.mmdb",
token: Secret::from("token-wxyz"),
}
);
assert!(!rendered.contains("token-wxyz"), "{rendered}");
assert!(rendered.contains(REDACTED), "{rendered}");
assert!(
rendered.contains("https://example.invalid/db.mmdb"),
"{rendered}"
);
}
#[test]
fn expose_returns_the_plaintext() {
assert_eq!(Secret::from("licence-abcd").expose(), "licence-abcd");
}
#[test]
fn serialising_writes_the_redaction_not_the_value() {
let secret = Secret::from("token-wxyz");
let json = serde_json::to_string(&secret).unwrap();
assert_eq!(json, r#""***REDACTED***""#);
assert!(!json.contains("token-wxyz"), "{json}");
}
#[test]
fn deserialising_reads_the_plaintext_a_secrets_layer_supplied() {
let secret: Secret = serde_json::from_str(r#""token-wxyz""#).unwrap();
assert_eq!(secret.expose(), "token-wxyz");
}
#[cfg(feature = "geoip-download")]
#[test]
fn a_whole_config_serialises_without_its_credentials() {
let config = crate::geoip::AutoDownloadConfig {
maxmind_account_id: Some("account-1234".into()),
maxmind_license_key: Some("licence-abcd".into()),
ipinfo_token: Some("token-wxyz".into()),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(!json.contains("account-1234"), "{json}");
assert!(!json.contains("licence-abcd"), "{json}");
assert!(!json.contains("token-wxyz"), "{json}");
}
#[test]
fn empty_reads_as_not_configured() {
assert!(Secret::from("").is_empty());
assert!(!Secret::from("x").is_empty());
}
}