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::sync::{Mutex, OnceLock};
9use uuid::Uuid;
10
11type HmacSha256 = Hmac<Sha256>;
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct RedactionReceipt {
15    pub receipt_id: String,
16    pub timestamp: String,
17    pub service_name: String,
18    pub field_path: String,
19    pub action: String,
20    pub original_hash: String,
21    pub hmac: Option<String>,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25struct ReceiptConfig {
26    enabled: bool,
27    signing_key: Option<String>,
28    service_name: String,
29    test_mode: bool,
30}
31
32impl Default for ReceiptConfig {
33    fn default() -> Self {
34        Self {
35            enabled: false,
36            signing_key: None,
37            service_name: "unknown".to_string(),
38            test_mode: false,
39        }
40    }
41}
42
43static CONFIG: OnceLock<Mutex<ReceiptConfig>> = OnceLock::new();
44static RECEIPTS: OnceLock<Mutex<Vec<RedactionReceipt>>> = OnceLock::new();
45
46fn config() -> &'static Mutex<ReceiptConfig> {
47    CONFIG.get_or_init(|| Mutex::new(ReceiptConfig::default()))
48}
49
50fn receipts() -> &'static Mutex<Vec<RedactionReceipt>> {
51    RECEIPTS.get_or_init(|| Mutex::new(Vec::new()))
52}
53
54fn bytes_to_hex(bytes: &[u8]) -> String {
55    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
56}
57
58pub fn enable_receipts(enabled: bool, signing_key: Option<&str>, service_name: Option<&str>) {
59    let test_mode = config()
60        .lock()
61        .expect("receipt config lock poisoned")
62        .test_mode;
63    *config().lock().expect("receipt config lock poisoned") = ReceiptConfig {
64        enabled,
65        signing_key: signing_key.map(str::to_string),
66        service_name: service_name.unwrap_or("unknown").to_string(),
67        test_mode,
68    };
69}
70
71pub fn emit_receipt(field_path: &str, action: &str, original_value: &str) {
72    let snapshot = config()
73        .lock()
74        .expect("receipt config lock poisoned")
75        .clone();
76    if !snapshot.enabled {
77        return;
78    }
79
80    let original_hash = {
81        let mut hasher = Sha256::new();
82        hasher.update(original_value.as_bytes());
83        bytes_to_hex(&hasher.finalize())
84    };
85    let receipt_id = Uuid::new_v4().to_string();
86    let timestamp = format!("{:?}", std::time::SystemTime::now());
87    let hmac = snapshot.signing_key.as_ref().map(|key| {
88        let payload = format!(
89            "{}|{}|{}|{}|{}",
90            receipt_id, timestamp, field_path, action, original_hash
91        );
92        let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("valid HMAC key");
93        mac.update(payload.as_bytes());
94        bytes_to_hex(&mac.finalize().into_bytes())
95    });
96
97    let receipt = RedactionReceipt {
98        receipt_id,
99        timestamp,
100        service_name: snapshot.service_name,
101        field_path: field_path.to_string(),
102        action: action.to_string(),
103        original_hash,
104        hmac,
105    };
106
107    if snapshot.test_mode {
108        receipts()
109            .lock()
110            .expect("receipt log lock poisoned")
111            .push(receipt);
112    }
113}
114
115pub fn get_emitted_receipts_for_tests() -> Vec<RedactionReceipt> {
116    receipts()
117        .lock()
118        .expect("receipt log lock poisoned")
119        .clone()
120}
121
122pub fn reset_receipts_for_tests() {
123    *config().lock().expect("receipt config lock poisoned") = ReceiptConfig {
124        test_mode: true,
125        ..ReceiptConfig::default()
126    };
127    receipts()
128        .lock()
129        .expect("receipt log lock poisoned")
130        .clear();
131}