provide_telemetry/
receipts.rs1use std::collections::VecDeque;
21use std::sync::{Arc, Mutex, OnceLock};
22
23use hmac::{Hmac, Mac};
24use serde_json::Value;
25use sha2::{Digest, Sha256};
26use std::fmt::Write;
27use uuid::Uuid;
28
29use crate::errors::ConfigurationError;
30pub use crate::jcs::{canonical_json, canonical_number};
31
32type HmacSha256 = Hmac<Sha256>;
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct RedactionReceipt {
37 pub receipt_id: String,
38 pub timestamp: String,
39 pub service_name: String,
40 pub field_path: String,
41 pub action: String,
42 pub original_hash: String,
43 pub hmac: Option<String>,
44}
45
46pub trait ReceiptSink: Send + Sync {
52 fn emit(&self, receipt: &RedactionReceipt) -> bool;
53}
54
55pub const TEST_RECEIPT_CAPACITY: usize = 1024;
57
58#[derive(Debug, Default)]
64pub struct TestReceiptCollector {
65 receipts: Mutex<VecDeque<RedactionReceipt>>,
66}
67
68impl TestReceiptCollector {
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn receipts(&self) -> Vec<RedactionReceipt> {
74 crate::_lock::lock(&self.receipts).iter().cloned().collect()
75 }
76
77 pub fn clear(&self) {
78 crate::_lock::lock(&self.receipts).clear();
79 }
80}
81
82impl ReceiptSink for TestReceiptCollector {
83 fn emit(&self, receipt: &RedactionReceipt) -> bool {
84 let mut receipts = crate::_lock::lock(&self.receipts);
85 if receipts.len() == TEST_RECEIPT_CAPACITY {
86 receipts.pop_front();
87 }
88 receipts.push_back(receipt.clone());
89 true
90 }
91}
92
93pub struct SignReceiptOptions<'a> {
97 pub receipt_id: &'a str,
98 pub timestamp: &'a str,
99 pub field_path: &'a str,
100 pub action: &'a str,
101 pub service_name: &'a str,
102 pub key: Option<&'a [u8]>,
104}
105
106fn bytes_to_hex(bytes: &[u8]) -> String {
107 let mut hex = String::with_capacity(bytes.len() * 2);
108 for byte in bytes {
109 write!(&mut hex, "{byte:02x}").expect("writing to string cannot fail");
110 }
111 hex
112}
113
114pub fn receipt_payload(receipt: &RedactionReceipt) -> String {
116 format!(
117 "{}|{}|{}|{}|{}",
118 receipt.receipt_id,
119 receipt.timestamp,
120 receipt.field_path,
121 receipt.action,
122 receipt.original_hash
123 )
124}
125
126pub fn sign_receipt(input: &Value, options: SignReceiptOptions<'_>) -> RedactionReceipt {
128 let mut hasher = Sha256::new();
129 hasher.update(canonical_json(input).as_bytes());
130 let mut receipt = RedactionReceipt {
131 receipt_id: options.receipt_id.to_string(),
132 timestamp: options.timestamp.to_string(),
133 service_name: options.service_name.to_string(),
134 field_path: options.field_path.to_string(),
135 action: options.action.to_string(),
136 original_hash: bytes_to_hex(&hasher.finalize()),
137 hmac: None,
138 };
139 receipt.hmac = options.key.map(|key| {
140 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts a key of any length");
141 mac.update(receipt_payload(&receipt).as_bytes());
142 bytes_to_hex(&mac.finalize().into_bytes())
143 });
144 receipt
145}
146
147pub fn emit_receipt(receipt: &RedactionReceipt, sink: &dyn ReceiptSink) {
155 let accepted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink.emit(receipt)))
156 .unwrap_or(false);
157 if !accepted {
158 crate::health::increment_receipt_failures();
159 }
160}
161
162#[derive(Clone, Default)]
163struct ReceiptConfig {
164 enabled: bool,
165 signing_key: Option<String>,
166 service_name: Option<String>,
167 sink: Option<Arc<dyn ReceiptSink>>,
168 test_mode: bool,
169}
170
171#[derive(Clone, Default)]
173pub struct ReceiptOptions {
174 pub enabled: bool,
175 pub signing_key: Option<String>,
176 pub service_name: Option<String>,
177 pub sink: Option<Arc<dyn ReceiptSink>>,
179}
180
181const DEFAULT_SERVICE_NAME: &str = "unknown";
182
183static CONFIG: OnceLock<Mutex<ReceiptConfig>> = OnceLock::new();
184static TEST_COLLECTOR: OnceLock<TestReceiptCollector> = OnceLock::new();
185
186#[cfg_attr(test, mutants::skip)] fn default_receipt_config_mutex() -> Mutex<ReceiptConfig> {
188 Mutex::new(ReceiptConfig::default())
189}
190
191fn config() -> &'static Mutex<ReceiptConfig> {
192 CONFIG.get_or_init(default_receipt_config_mutex)
193}
194
195fn test_collector() -> &'static TestReceiptCollector {
196 TEST_COLLECTOR.get_or_init(TestReceiptCollector::new)
197}
198
199pub fn enable_receipts(options: ReceiptOptions) -> Result<(), ConfigurationError> {
206 let mut current = crate::_lock::lock(config());
207 if options.enabled && !current.test_mode && options.sink.is_none() {
208 return Err(ConfigurationError::new(
209 "receipts are enabled but no ReceiptSink is configured; generated receipts \
210 would be signed and then discarded. Pass a sink, or disable receipts.",
211 ));
212 }
213 *current = ReceiptConfig {
214 enabled: options.enabled,
215 signing_key: options.signing_key,
216 service_name: options.service_name,
217 sink: options.sink,
218 test_mode: current.test_mode,
219 };
220 Ok(())
221}
222
223pub(crate) fn record_redaction(field_path: &str, action: &str, original_value: &Value) {
225 let snapshot = crate::_lock::lock(config()).clone();
226 if !snapshot.enabled {
227 return;
228 }
229 let receipt = sign_receipt(
230 original_value,
231 SignReceiptOptions {
232 receipt_id: &Uuid::new_v4().to_string(),
233 timestamp: &crate::logger::now_iso8601(),
234 field_path,
235 action,
236 service_name: snapshot
237 .service_name
238 .as_deref()
239 .unwrap_or(DEFAULT_SERVICE_NAME),
240 key: snapshot.signing_key.as_ref().map(|key| key.as_bytes()),
241 },
242 );
243 match snapshot.sink {
246 Some(sink) => emit_receipt(&receipt, sink.as_ref()),
247 None => emit_receipt(&receipt, test_collector()),
248 }
249}
250
251pub fn get_emitted_receipts_for_tests() -> Vec<RedactionReceipt> {
252 test_collector().receipts()
253}
254
255pub fn reset_receipts_for_tests() {
256 *crate::_lock::lock(config()) = ReceiptConfig {
257 test_mode: true,
258 ..ReceiptConfig::default()
259 };
260 test_collector().clear();
261}
262
263pub fn _set_test_mode_for_tests(test_mode: bool) {
266 crate::_lock::lock(config()).test_mode = test_mode;
267}
268
269#[cfg(test)]
270#[path = "receipts_tests.rs"]
271mod tests;