use std::convert::TryFrom;
#[cfg(feature = "hmac-codec")]
use std::sync::OnceLock;
#[cfg(feature = "hmac-codec")]
use hmac::{Hmac, KeyInit, Mac};
#[cfg(feature = "hmac-codec")]
use sha2::Sha256;
use thiserror::Error;
#[cfg(feature = "hmac-codec")]
use super::{BuiltInPresentationError, PresentationStartupError};
use super::{
PresentationCodec, PresentationCodecInfo, Queryability, Reversibility,
ReversiblePresentationCodec, ReversibleTryPresentationCodec, TryPresentationCodec,
};
use crate::presentation::query::{
PresentationOrderCodec, PresentationQueryCodec, PresentationQueryField,
};
#[cfg(feature = "hmac-codec")]
type HmacSha256 = Hmac<Sha256>;
#[cfg(feature = "hmac-codec")]
const HMAC_KEY_ENV: &str = "DJOGI_PRESENTATION_HMAC_KEY";
#[cfg(feature = "hmac-codec")]
static HMAC_KEY: OnceLock<[u8; 32]> = OnceLock::new();
#[cfg(all(test, feature = "hmac-codec"))]
pub(crate) static TEST_HMAC_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(feature = "hmac-codec")]
fn parse_hmac_key(hex: &str, env_name: &'static str) -> Result<[u8; 32], PresentationStartupError> {
let bytes = hex.as_bytes();
if bytes.len() != 64 {
return Err(PresentationStartupError::InvalidHexLength {
name: env_name,
actual: bytes.len(),
});
}
let mut key = [0u8; 32];
for (chunk_idx, chunk) in bytes.chunks(2).enumerate() {
let hi = decode_hex_nibble(chunk[0], chunk_idx * 2, env_name)?;
let lo = decode_hex_nibble(chunk[1], chunk_idx * 2 + 1, env_name)?;
key[chunk_idx] = (hi << 4) | lo;
}
Ok(key)
}
#[cfg(feature = "hmac-codec")]
fn decode_hex_nibble(
byte: u8,
idx: usize,
env_name: &'static str,
) -> Result<u8, PresentationStartupError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Err(PresentationStartupError::NonLowercaseHexByte {
name: env_name,
idx,
}),
_ => Err(PresentationStartupError::InvalidHexByte {
name: env_name,
idx,
}),
}
}
#[cfg(feature = "hmac-codec")]
fn validate_startup_for_hmac_key() -> Result<(), PresentationStartupError> {
let raw = match std::env::var(HMAC_KEY_ENV) {
Ok(v) => v,
Err(std::env::VarError::NotPresent) => {
return Err(PresentationStartupError::MissingEnvVar { name: HMAC_KEY_ENV });
}
Err(std::env::VarError::NotUnicode(_)) => {
return Err(PresentationStartupError::NonUnicodeEnvVar { name: HMAC_KEY_ENV });
}
};
let key_bytes = parse_hmac_key(&raw, HMAC_KEY_ENV)?;
let _ = HMAC_KEY.set(key_bytes);
Ok(())
}
#[cfg(feature = "hmac-codec")]
fn hmac_sha256_hex_string_present_with_cached_key(
value: &str,
cached_key: Option<&[u8; 32]>,
) -> Result<HmacSha256Hex, BuiltInPresentationError> {
let key = cached_key.ok_or(BuiltInPresentationError::KeyNotValidated { env: HMAC_KEY_ENV })?;
let mut mac = HmacSha256::new_from_slice(key.as_ref())
.expect("HMAC-SHA256 accepts any key length; 32-byte key is always valid");
mac.update(value.as_bytes());
let result = mac.finalize().into_bytes();
Ok(HmacSha256Hex::new_unchecked(hex_encode(&result)))
}
pub struct Identity;
impl<T> PresentationCodecInfo<T> for Identity
where
T: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
type Output = T;
const REVERSIBILITY: Reversibility = Reversibility::Reversible;
const QUERYABILITY: Queryability = Queryability::PredicateAndOrder;
}
impl<T> PresentationCodec<T> for Identity
where
T: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
fn present(value: &T) -> T {
value.clone()
}
}
impl<T> TryPresentationCodec<T> for Identity
where
T: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
type Error = std::convert::Infallible;
fn try_present(value: &T) -> Result<T, Self::Error> {
Ok(value.clone())
}
}
impl<T> ReversiblePresentationCodec<T> for Identity
where
T: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
type ReverseError = std::convert::Infallible;
fn try_reverse(value: &T) -> Result<T, Self::ReverseError> {
Ok(value.clone())
}
}
impl<T> ReversibleTryPresentationCodec<T> for Identity
where
T: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
type ReverseError = std::convert::Infallible;
fn try_reverse(value: &T) -> Result<T, Self::ReverseError> {
Ok(value.clone())
}
}
impl<T> PresentationQueryCodec<T> for Identity
where
T: Clone
+ std::fmt::Debug
+ serde::Serialize
+ serde::de::DeserializeOwned
+ crate::query::IntoFilterValue
+ 'static,
{
type QueryValue = T;
fn to_query_value_and_build<M: crate::model::Model>(
field: PresentationQueryField<M, T>,
value: T,
) -> crate::query::Q<M> {
field.eq_storage(value)
}
}
impl<T> PresentationOrderCodec<T> for Identity
where
T: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
}
pub struct MaskString;
const MASK_LITERAL: &str = "[REDACTED]";
impl PresentationCodecInfo<String> for MaskString {
type Output = String;
const REVERSIBILITY: Reversibility = Reversibility::OneWay;
const QUERYABILITY: Queryability = Queryability::Disabled;
}
impl PresentationCodec<String> for MaskString {
fn present(_value: &String) -> String {
MASK_LITERAL.to_string()
}
}
impl TryPresentationCodec<String> for MaskString {
type Error = std::convert::Infallible;
fn try_present(value: &String) -> Result<String, std::convert::Infallible> {
Ok(MaskString::present(value))
}
}
pub struct MaskOptionString;
impl PresentationCodecInfo<Option<String>> for MaskOptionString {
type Output = Option<String>;
const REVERSIBILITY: Reversibility = Reversibility::OneWay;
const QUERYABILITY: Queryability = Queryability::Disabled;
}
impl PresentationCodec<Option<String>> for MaskOptionString {
fn present(value: &Option<String>) -> Option<String> {
value.as_ref().map(|_| MASK_LITERAL.to_string())
}
}
impl TryPresentationCodec<Option<String>> for MaskOptionString {
type Error = std::convert::Infallible;
fn try_present(value: &Option<String>) -> Result<Option<String>, std::convert::Infallible> {
Ok(MaskOptionString::present(value))
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "String")]
pub struct HmacSha256Hex(pub(crate) String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum HmacSha256HexError {
#[error("HmacSha256Hex must be exactly 64 lowercase hex characters; got {actual}")]
InvalidLength {
actual: usize,
},
#[error(
"HmacSha256Hex must contain only lowercase hex characters; invalid byte 0x{byte:02x} at index {idx}"
)]
InvalidByte {
idx: usize,
byte: u8,
},
}
fn validate_hmac_sha256_hex(hex: &str) -> Result<(), HmacSha256HexError> {
let bytes = hex.as_bytes();
if bytes.len() != 64 {
return Err(HmacSha256HexError::InvalidLength {
actual: bytes.len(),
});
}
for (idx, &byte) in bytes.iter().enumerate() {
match byte {
b'0'..=b'9' | b'a'..=b'f' => {}
_ => {
return Err(HmacSha256HexError::InvalidByte { idx, byte });
}
}
}
Ok(())
}
impl HmacSha256Hex {
#[cfg(feature = "hmac-codec")]
pub(crate) fn new_unchecked(hex: String) -> Self {
Self(hex)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for HmacSha256Hex {
type Error = HmacSha256HexError;
fn try_from(value: String) -> Result<Self, Self::Error> {
validate_hmac_sha256_hex(&value)?;
Ok(Self(value))
}
}
impl<'a> tokio_postgres::types::FromSql<'a> for HmacSha256Hex {
fn from_sql(
ty: &tokio_postgres::types::Type,
raw: &'a [u8],
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
let s = String::from_sql(ty, raw)?;
HmacSha256Hex::try_from(s).map_err(|err| Box::new(err) as _)
}
fn accepts(ty: &tokio_postgres::types::Type) -> bool {
<String as tokio_postgres::types::FromSql<'_>>::accepts(ty)
}
}
#[cfg(feature = "hmac-codec")]
pub struct HmacSha256HexString;
#[cfg(feature = "hmac-codec")]
impl PresentationCodecInfo<String> for HmacSha256HexString {
type Output = HmacSha256Hex;
const REVERSIBILITY: Reversibility = Reversibility::OneWay;
const QUERYABILITY: Queryability = Queryability::Disabled;
fn validate_startup() -> Result<(), PresentationStartupError> {
validate_startup_for_hmac_key()
}
}
#[cfg(feature = "hmac-codec")]
impl TryPresentationCodec<String> for HmacSha256HexString {
type Error = BuiltInPresentationError;
fn try_present(value: &String) -> Result<HmacSha256Hex, BuiltInPresentationError> {
hmac_sha256_hex_string_present_with_cached_key(value, HMAC_KEY.get())
}
}
#[cfg(feature = "hmac-codec")]
pub struct HmacSha256HexOptionString;
#[cfg(feature = "hmac-codec")]
impl PresentationCodecInfo<Option<String>> for HmacSha256HexOptionString {
type Output = Option<HmacSha256Hex>;
const REVERSIBILITY: Reversibility = Reversibility::OneWay;
const QUERYABILITY: Queryability = Queryability::Disabled;
fn validate_startup() -> Result<(), PresentationStartupError> {
validate_startup_for_hmac_key()
}
}
#[cfg(feature = "hmac-codec")]
impl TryPresentationCodec<Option<String>> for HmacSha256HexOptionString {
type Error = BuiltInPresentationError;
fn try_present(
value: &Option<String>,
) -> Result<Option<HmacSha256Hex>, BuiltInPresentationError> {
match value {
None => Ok(None),
Some(s) => HmacSha256HexString::try_present(s).map(Some),
}
}
}
#[cfg(feature = "hmac-codec")]
fn hex_encode(bytes: &[u8]) -> String {
const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX_DIGITS[(b >> 4) as usize] as char);
out.push(HEX_DIGITS[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::convert::TryFrom;
#[test]
fn identity_present_string_returns_same_value() {
let input = "hello".to_string();
let output = Identity::present(&input);
assert_eq!(output, input);
}
#[test]
fn identity_present_u32_returns_same_value() {
let input: u32 = 42;
let output = Identity::present(&input);
assert_eq!(output, input);
}
#[test]
fn identity_is_reversible() {
assert_eq!(
<Identity as PresentationCodecInfo<String>>::REVERSIBILITY,
Reversibility::Reversible
);
}
#[test]
fn identity_is_queryable_predicate_and_order() {
assert_eq!(
<Identity as PresentationCodecInfo<String>>::QUERYABILITY,
Queryability::PredicateAndOrder
);
}
#[test]
fn mask_string_present_does_not_return_original() {
let input = "alice@example.com".to_string();
let output = MaskString::present(&input);
assert_ne!(
output, input,
"MaskString must not expose the original value"
);
assert_eq!(output, MASK_LITERAL);
}
#[test]
fn mask_string_is_one_way() {
assert_eq!(
<MaskString as PresentationCodecInfo<String>>::REVERSIBILITY,
Reversibility::OneWay
);
}
#[test]
fn mask_string_queryability_disabled() {
assert_eq!(
<MaskString as PresentationCodecInfo<String>>::QUERYABILITY,
Queryability::Disabled
);
}
#[test]
fn mask_string_try_present_delegates_to_present() {
let input = "alice@example.com".to_string();
let result = MaskString::try_present(&input);
assert!(result.is_ok(), "try_present must never fail for MaskString");
assert_eq!(result.unwrap(), MaskString::present(&input));
}
#[test]
fn mask_option_string_none_preserved() {
let input: Option<String> = None;
let output = MaskOptionString::present(&input);
assert_eq!(output, None, "None must be preserved by MaskOptionString");
}
#[test]
fn mask_option_string_some_is_masked() {
let input = Some("secret".to_string());
let output = MaskOptionString::present(&input);
assert_eq!(output, Some(MASK_LITERAL.to_string()));
assert_ne!(output.unwrap(), "secret");
}
#[test]
fn mask_option_string_try_present_matches_present() {
let none_input: Option<String> = None;
let some_input = Some("secret".to_string());
let none_output = MaskOptionString::try_present(&none_input);
let some_output = MaskOptionString::try_present(&some_input);
assert_eq!(none_output, Ok(None));
assert_eq!(some_output, Ok(Some(MASK_LITERAL.to_string())));
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_key_validation_accepts_valid_lowercase_hex() {
let valid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899";
assert_eq!(valid.len(), 64);
let result = parse_hmac_key(valid, "TEST_KEY");
assert!(
result.is_ok(),
"valid 64 lowercase hex should parse: {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_key_validation_rejects_uppercase_hex() {
let uppercase = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899";
assert_eq!(uppercase.len(), 64);
let result = parse_hmac_key(uppercase, "TEST_KEY");
assert!(
matches!(
result,
Err(PresentationStartupError::NonLowercaseHexByte { .. })
),
"uppercase hex must produce NonLowercaseHexByte: {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_key_validation_rejects_wrong_length() {
let short = "a".repeat(62);
assert_eq!(short.len(), 62);
let result = parse_hmac_key(&short, "TEST_KEY");
assert!(
matches!(
result,
Err(PresentationStartupError::InvalidHexLength { actual: 62, .. })
),
"62-char hex must produce InvalidHexLength: {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_key_validation_rejects_63_chars() {
let s63 = "a".repeat(63);
let result = parse_hmac_key(&s63, "TEST_KEY");
assert!(
matches!(
result,
Err(PresentationStartupError::InvalidHexLength { actual: 63, .. })
),
"63-char hex must produce InvalidHexLength: {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_key_validation_rejects_non_hex_byte() {
let bad = "aabbccddeeff00112233445566778899aabbccddeeff001122334455667788gg";
assert_eq!(bad.len(), 64);
let result = parse_hmac_key(bad, "TEST_KEY");
assert!(
matches!(result, Err(PresentationStartupError::InvalidHexByte { .. })),
"non-hex byte must produce InvalidHexByte: {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_string_cache_miss_returns_key_not_validated() {
let result = hmac_sha256_hex_string_present_with_cached_key("test", None);
assert!(
matches!(
result,
Err(BuiltInPresentationError::KeyNotValidated {
env: "DJOGI_PRESENTATION_HMAC_KEY"
})
),
"cache miss must return KeyNotValidated: {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_two_different_inputs_produce_different_outputs() {
let _guard = TEST_HMAC_ENV_MUTEX
.lock()
.unwrap_or_else(|p| p.into_inner());
let test_key = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
#[allow(unsafe_code)]
unsafe {
std::env::set_var("DJOGI_PRESENTATION_HMAC_KEY", test_key);
}
let _ = validate_startup_for_hmac_key();
if let Some(key) = HMAC_KEY.get() {
let mut mac1 = HmacSha256::new_from_slice(key.as_ref()).unwrap();
mac1.update(b"input_a");
let out1 = hex_encode(&mac1.finalize().into_bytes());
let mut mac2 = HmacSha256::new_from_slice(key.as_ref()).unwrap();
mac2.update(b"input_b");
let out2 = hex_encode(&mac2.finalize().into_bytes());
assert_ne!(
out1, out2,
"different inputs must produce different HMAC outputs"
);
assert_eq!(out1.len(), 64, "HMAC output must be 64 hex chars");
assert_eq!(out2.len(), 64, "HMAC output must be 64 hex chars");
}
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_option_string_none_preserved() {
let result = HmacSha256HexOptionString::try_present(&None);
assert!(
matches!(result, Ok(None)),
"None input must produce Ok(None): {:?}",
result
);
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hmac_sha256_hex_as_str_returns_inner() {
let h = HmacSha256Hex::new_unchecked("aabb".to_string());
assert_eq!(h.as_str(), "aabb");
}
#[test]
fn hmac_sha256_hex_try_from_valid_lowercase_hex_succeeds() {
let input = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = HmacSha256Hex::try_from(input.to_string()).expect("valid hex must parse");
assert_eq!(h.as_str(), input);
}
#[test]
fn hmac_sha256_hex_try_from_rejects_invalid_length() {
let result = HmacSha256Hex::try_from("a".repeat(63));
assert!(result.is_err(), "63-char input must be rejected");
}
#[test]
fn hmac_sha256_hex_try_from_rejects_uppercase_hex() {
let result = HmacSha256Hex::try_from("A".repeat(64));
assert!(result.is_err(), "uppercase hex must be rejected");
}
#[test]
fn hmac_sha256_hex_from_sql_rejects_invalid_value() {
use tokio_postgres::types::FromSql;
let raw = b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let result = HmacSha256Hex::from_sql(&tokio_postgres::types::Type::TEXT, raw);
assert!(result.is_err(), "invalid Postgres text must be rejected");
}
#[test]
fn hmac_sha256_hex_from_sql_accepts_text_type() {
use tokio_postgres::types::FromSql;
assert!(
HmacSha256Hex::accepts(&tokio_postgres::types::Type::TEXT),
"HmacSha256Hex must accept the Postgres TEXT type"
);
}
#[test]
fn hmac_sha256_hex_serde_rejects_invalid_string() {
let json = format!("\"{}\"", "A".repeat(64));
let result = serde_json::from_str::<HmacSha256Hex>(&json);
assert!(result.is_err(), "invalid serde string must be rejected");
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hex_encode_known_values() {
assert_eq!(hex_encode(&[0x00]), "00");
assert_eq!(hex_encode(&[0xff]), "ff");
assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
assert_eq!(hex_encode(&[]), "");
}
#[cfg(feature = "hmac-codec")]
#[test]
fn hex_encode_output_is_lowercase() {
let bytes: Vec<u8> = (0..=255u8).collect();
let encoded = hex_encode(&bytes);
assert!(
encoded
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
"hex_encode must produce only lowercase hex characters"
);
}
}