Skip to main content

softchat/
diagnostics.rs

1//! Stable, redacted build and operation diagnostics.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4
5/// Stable build metadata safe to include in logs and crash reports.
6#[derive(Clone, Debug, Eq, PartialEq)]
7#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
8pub struct BuildFingerprint {
9    /// Semantic library version.
10    pub version: String,
11    /// Optional source revision supplied by the release build.
12    pub revision: String,
13    /// Accepted protocol-profile version.
14    pub protocol_profile: u32,
15    /// Compiled target architecture.
16    pub target_arch: String,
17    /// Compiled target operating system family.
18    pub target_os: String,
19}
20
21/// Monotonic process-local counters containing no protocol values.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
24pub struct DiagnosticsSnapshot {
25    /// Successful NIP-44 encryptions.
26    pub nip44_encryptions: u64,
27    /// Successful authenticated NIP-44 decryptions.
28    pub nip44_decryptions: u64,
29    /// Successfully verified signed events.
30    pub verified_events: u64,
31    /// Successfully authored signed events.
32    pub signed_events: u64,
33    /// Successfully created NIP-59 recipient copies.
34    pub wrapped_envelopes: u64,
35    /// Successfully authenticated NIP-59 envelopes.
36    pub unwrapped_envelopes: u64,
37    /// Rejected operations across instrumented trust boundaries.
38    pub rejected_operations: u64,
39    /// Aggregate caller-controlled input byte count.
40    pub input_bytes: u64,
41    /// Aggregate returned byte count.
42    pub output_bytes: u64,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub(crate) enum Operation {
47    Nip44Encrypt,
48    Nip44Decrypt,
49    VerifyEvent,
50    SignEvent,
51    WrapEnvelope,
52    UnwrapEnvelope,
53}
54
55static NIP44_ENCRYPTIONS: AtomicU64 = AtomicU64::new(0);
56static NIP44_DECRYPTIONS: AtomicU64 = AtomicU64::new(0);
57static VERIFIED_EVENTS: AtomicU64 = AtomicU64::new(0);
58static SIGNED_EVENTS: AtomicU64 = AtomicU64::new(0);
59static WRAPPED_ENVELOPES: AtomicU64 = AtomicU64::new(0);
60static UNWRAPPED_ENVELOPES: AtomicU64 = AtomicU64::new(0);
61static REJECTED_OPERATIONS: AtomicU64 = AtomicU64::new(0);
62static INPUT_BYTES: AtomicU64 = AtomicU64::new(0);
63static OUTPUT_BYTES: AtomicU64 = AtomicU64::new(0);
64
65/// Return stable build metadata.
66#[cfg_attr(feature = "native-bindings", uniffi::export)]
67#[must_use]
68pub fn build_fingerprint() -> BuildFingerprint {
69    BuildFingerprint {
70        version: env!("CARGO_PKG_VERSION").to_owned(),
71        revision: option_env!("SOFTCHAT_BUILD_REVISION")
72            .unwrap_or("unversioned")
73            .to_owned(),
74        protocol_profile: 1,
75        target_arch: std::env::consts::ARCH.to_owned(),
76        target_os: std::env::consts::OS.to_owned(),
77    }
78}
79
80/// Read monotonic process-local counters using relaxed atomics.
81#[cfg_attr(feature = "native-bindings", uniffi::export)]
82#[must_use]
83pub fn diagnostics_snapshot() -> DiagnosticsSnapshot {
84    DiagnosticsSnapshot {
85        nip44_encryptions: NIP44_ENCRYPTIONS.load(Ordering::Relaxed),
86        nip44_decryptions: NIP44_DECRYPTIONS.load(Ordering::Relaxed),
87        verified_events: VERIFIED_EVENTS.load(Ordering::Relaxed),
88        signed_events: SIGNED_EVENTS.load(Ordering::Relaxed),
89        wrapped_envelopes: WRAPPED_ENVELOPES.load(Ordering::Relaxed),
90        unwrapped_envelopes: UNWRAPPED_ENVELOPES.load(Ordering::Relaxed),
91        rejected_operations: REJECTED_OPERATIONS.load(Ordering::Relaxed),
92        input_bytes: INPUT_BYTES.load(Ordering::Relaxed),
93        output_bytes: OUTPUT_BYTES.load(Ordering::Relaxed),
94    }
95}
96
97pub(crate) fn record_success(operation: Operation, input_bytes: usize, output_bytes: usize) {
98    counter(operation).fetch_add(1, Ordering::Relaxed);
99    INPUT_BYTES.fetch_add(saturating_u64(input_bytes), Ordering::Relaxed);
100    OUTPUT_BYTES.fetch_add(saturating_u64(output_bytes), Ordering::Relaxed);
101}
102
103pub(crate) fn record_rejection(input_bytes: usize) {
104    REJECTED_OPERATIONS.fetch_add(1, Ordering::Relaxed);
105    INPUT_BYTES.fetch_add(saturating_u64(input_bytes), Ordering::Relaxed);
106}
107
108fn counter(operation: Operation) -> &'static AtomicU64 {
109    match operation {
110        Operation::Nip44Encrypt => &NIP44_ENCRYPTIONS,
111        Operation::Nip44Decrypt => &NIP44_DECRYPTIONS,
112        Operation::VerifyEvent => &VERIFIED_EVENTS,
113        Operation::SignEvent => &SIGNED_EVENTS,
114        Operation::WrapEnvelope => &WRAPPED_ENVELOPES,
115        Operation::UnwrapEnvelope => &UNWRAPPED_ENVELOPES,
116    }
117}
118
119fn saturating_u64(value: usize) -> u64 {
120    u64::try_from(value).unwrap_or(u64::MAX)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn fingerprint_and_snapshots_are_redacted() {
129        let fingerprint = build_fingerprint();
130        assert_eq!(fingerprint.protocol_profile, 1);
131        assert!(!fingerprint.version.is_empty());
132
133        record_success(Operation::Nip44Encrypt, 12, 34);
134        record_rejection(56);
135        let snapshot = diagnostics_snapshot();
136        assert!(snapshot.nip44_encryptions >= 1);
137        assert!(snapshot.rejected_operations >= 1);
138        let debug = format!("{snapshot:?}");
139        assert!(!debug.contains("plaintext"));
140        assert!(!debug.contains("ciphertext"));
141    }
142}