use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fmt::Write;
use uuid::Uuid;
use crate::errors::ConfigurationError;
pub use crate::jcs::{canonical_json, canonical_number};
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RedactionReceipt {
pub receipt_id: String,
pub timestamp: String,
pub service_name: String,
pub field_path: String,
pub action: String,
pub original_hash: String,
pub hmac: Option<String>,
}
pub trait ReceiptSink: Send + Sync {
fn emit(&self, receipt: &RedactionReceipt) -> bool;
}
pub const TEST_RECEIPT_CAPACITY: usize = 1024;
#[derive(Debug, Default)]
pub struct TestReceiptCollector {
receipts: Mutex<VecDeque<RedactionReceipt>>,
}
impl TestReceiptCollector {
pub fn new() -> Self {
Self::default()
}
pub fn receipts(&self) -> Vec<RedactionReceipt> {
crate::_lock::lock(&self.receipts).iter().cloned().collect()
}
pub fn clear(&self) {
crate::_lock::lock(&self.receipts).clear();
}
}
impl ReceiptSink for TestReceiptCollector {
fn emit(&self, receipt: &RedactionReceipt) -> bool {
let mut receipts = crate::_lock::lock(&self.receipts);
if receipts.len() == TEST_RECEIPT_CAPACITY {
receipts.pop_front();
}
receipts.push_back(receipt.clone());
true
}
}
pub struct SignReceiptOptions<'a> {
pub receipt_id: &'a str,
pub timestamp: &'a str,
pub field_path: &'a str,
pub action: &'a str,
pub service_name: &'a str,
pub key: Option<&'a [u8]>,
}
fn bytes_to_hex(bytes: &[u8]) -> String {
let mut hex = String::with_capacity(bytes.len() * 2);
for byte in bytes {
write!(&mut hex, "{byte:02x}").expect("writing to string cannot fail");
}
hex
}
pub fn receipt_payload(receipt: &RedactionReceipt) -> String {
format!(
"{}|{}|{}|{}|{}",
receipt.receipt_id,
receipt.timestamp,
receipt.field_path,
receipt.action,
receipt.original_hash
)
}
pub fn sign_receipt(input: &Value, options: SignReceiptOptions<'_>) -> RedactionReceipt {
let mut hasher = Sha256::new();
hasher.update(canonical_json(input).as_bytes());
let mut receipt = RedactionReceipt {
receipt_id: options.receipt_id.to_string(),
timestamp: options.timestamp.to_string(),
service_name: options.service_name.to_string(),
field_path: options.field_path.to_string(),
action: options.action.to_string(),
original_hash: bytes_to_hex(&hasher.finalize()),
hmac: None,
};
receipt.hmac = options.key.map(|key| {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts a key of any length");
mac.update(receipt_payload(&receipt).as_bytes());
bytes_to_hex(&mac.finalize().into_bytes())
});
receipt
}
pub fn emit_receipt(receipt: &RedactionReceipt, sink: &dyn ReceiptSink) {
let accepted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink.emit(receipt)))
.unwrap_or(false);
if !accepted {
crate::health::increment_receipt_failures();
}
}
#[derive(Clone, Default)]
struct ReceiptConfig {
enabled: bool,
signing_key: Option<String>,
service_name: Option<String>,
sink: Option<Arc<dyn ReceiptSink>>,
test_mode: bool,
}
#[derive(Clone, Default)]
pub struct ReceiptOptions {
pub enabled: bool,
pub signing_key: Option<String>,
pub service_name: Option<String>,
pub sink: Option<Arc<dyn ReceiptSink>>,
}
const DEFAULT_SERVICE_NAME: &str = "unknown";
static CONFIG: OnceLock<Mutex<ReceiptConfig>> = OnceLock::new();
static TEST_COLLECTOR: OnceLock<TestReceiptCollector> = OnceLock::new();
#[cfg_attr(test, mutants::skip)] fn default_receipt_config_mutex() -> Mutex<ReceiptConfig> {
Mutex::new(ReceiptConfig::default())
}
fn config() -> &'static Mutex<ReceiptConfig> {
CONFIG.get_or_init(default_receipt_config_mutex)
}
fn test_collector() -> &'static TestReceiptCollector {
TEST_COLLECTOR.get_or_init(TestReceiptCollector::new)
}
pub fn enable_receipts(options: ReceiptOptions) -> Result<(), ConfigurationError> {
let mut current = crate::_lock::lock(config());
if options.enabled && !current.test_mode && options.sink.is_none() {
return Err(ConfigurationError::new(
"receipts are enabled but no ReceiptSink is configured; generated receipts \
would be signed and then discarded. Pass a sink, or disable receipts.",
));
}
*current = ReceiptConfig {
enabled: options.enabled,
signing_key: options.signing_key,
service_name: options.service_name,
sink: options.sink,
test_mode: current.test_mode,
};
Ok(())
}
pub(crate) fn record_redaction(field_path: &str, action: &str, original_value: &Value) {
let snapshot = crate::_lock::lock(config()).clone();
if !snapshot.enabled {
return;
}
let receipt = sign_receipt(
original_value,
SignReceiptOptions {
receipt_id: &Uuid::new_v4().to_string(),
timestamp: &crate::logger::now_iso8601(),
field_path,
action,
service_name: snapshot
.service_name
.as_deref()
.unwrap_or(DEFAULT_SERVICE_NAME),
key: snapshot.signing_key.as_ref().map(|key| key.as_bytes()),
},
);
match snapshot.sink {
Some(sink) => emit_receipt(&receipt, sink.as_ref()),
None => emit_receipt(&receipt, test_collector()),
}
}
pub fn get_emitted_receipts_for_tests() -> Vec<RedactionReceipt> {
test_collector().receipts()
}
pub fn reset_receipts_for_tests() {
*crate::_lock::lock(config()) = ReceiptConfig {
test_mode: true,
..ReceiptConfig::default()
};
test_collector().clear();
}
pub fn _set_test_mode_for_tests(test_mode: bool) {
crate::_lock::lock(config()).test_mode = test_mode;
}
#[cfg(test)]
#[path = "receipts_tests.rs"]
mod tests;