Skip to main content

provide_telemetry/
receipts.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use hmac::{Hmac, Mac};
7use sha2::{Digest, Sha256};
8use std::fmt::Write;
9use std::sync::{Mutex, OnceLock};
10use uuid::Uuid;
11
12type HmacSha256 = Hmac<Sha256>;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct RedactionReceipt {
16    pub receipt_id: String,
17    pub timestamp: String,
18    pub service_name: String,
19    pub field_path: String,
20    pub action: String,
21    pub original_hash: String,
22    pub hmac: Option<String>,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26struct ReceiptConfig {
27    enabled: bool,
28    signing_key: Option<String>,
29    service_name: String,
30    test_mode: bool,
31}
32
33impl Default for ReceiptConfig {
34    fn default() -> Self {
35        Self {
36            enabled: false,
37            signing_key: None,
38            service_name: "unknown".to_string(),
39            test_mode: false,
40        }
41    }
42}
43
44static CONFIG: OnceLock<Mutex<ReceiptConfig>> = OnceLock::new();
45static RECEIPTS: OnceLock<Mutex<Vec<RedactionReceipt>>> = OnceLock::new();
46
47#[cfg_attr(test, mutants::skip)] // Equivalent mutants only swap in Mutex::default().
48fn default_receipt_config_mutex() -> Mutex<ReceiptConfig> {
49    Mutex::new(ReceiptConfig::default())
50}
51
52#[cfg_attr(test, mutants::skip)] // Equivalent mutants only rewrite Vec::new() syntax.
53fn empty_receipts_mutex() -> Mutex<Vec<RedactionReceipt>> {
54    Mutex::new(Vec::new())
55}
56
57fn config() -> &'static Mutex<ReceiptConfig> {
58    CONFIG.get_or_init(default_receipt_config_mutex)
59}
60
61fn receipts() -> &'static Mutex<Vec<RedactionReceipt>> {
62    RECEIPTS.get_or_init(empty_receipts_mutex)
63}
64
65fn bytes_to_hex(bytes: &[u8]) -> String {
66    let mut hex = String::with_capacity(bytes.len() * 2);
67    for byte in bytes {
68        write!(&mut hex, "{byte:02x}").expect("writing to string cannot fail");
69    }
70    hex
71}
72
73pub fn enable_receipts(enabled: bool, signing_key: Option<&str>, service_name: Option<&str>) {
74    let test_mode = crate::_lock::lock(config()).test_mode;
75    *crate::_lock::lock(config()) = ReceiptConfig {
76        enabled,
77        signing_key: signing_key.map(str::to_string),
78        service_name: service_name.unwrap_or("unknown").to_string(),
79        test_mode,
80    };
81}
82
83pub fn emit_receipt(field_path: &str, action: &str, original_value: &str) {
84    let snapshot = crate::_lock::lock(config()).clone();
85    if !snapshot.enabled {
86        return;
87    }
88
89    let original_hash = {
90        let mut hasher = Sha256::new();
91        hasher.update(original_value.as_bytes());
92        bytes_to_hex(&hasher.finalize())
93    };
94    let receipt_id = Uuid::new_v4().to_string();
95    let timestamp = format!("{:?}", std::time::SystemTime::now());
96    let hmac = snapshot.signing_key.as_ref().map(|key| {
97        let payload = format!(
98            "{}|{}|{}|{}|{}",
99            receipt_id, timestamp, field_path, action, original_hash
100        );
101        let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("valid HMAC key");
102        mac.update(payload.as_bytes());
103        bytes_to_hex(&mac.finalize().into_bytes())
104    });
105
106    let receipt = RedactionReceipt {
107        receipt_id,
108        timestamp,
109        service_name: snapshot.service_name,
110        field_path: field_path.to_string(),
111        action: action.to_string(),
112        original_hash,
113        hmac,
114    };
115
116    if snapshot.test_mode {
117        crate::_lock::lock(receipts()).push(receipt);
118    }
119}
120
121pub fn get_emitted_receipts_for_tests() -> Vec<RedactionReceipt> {
122    crate::_lock::lock(receipts()).clone()
123}
124
125pub fn reset_receipts_for_tests() {
126    *crate::_lock::lock(config()) = ReceiptConfig {
127        test_mode: true,
128        ..ReceiptConfig::default()
129    };
130    crate::_lock::lock(receipts()).clear();
131}
132
133#[cfg(test)]
134#[path = "receipts_tests.rs"]
135mod tests;