Skip to main content

monoloop_contracts/
safe.rs

1//! Bounded, pre-redacted diagnostics (no secrets, prompts, or raw bodies).
2
3use crate::limits::TransactionLimits;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// Safe diagnostic code (closed vocabulary preferred; free-form codes are bounded).
8#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct DiagnosticCode(String);
10
11impl DiagnosticCode {
12    /// Maximum code bytes.
13    pub const MAX_BYTES: usize = 64;
14
15    /// Fallible constructor.
16    pub fn try_new(value: impl Into<String>) -> Result<Self, SafeDiagnosticError> {
17        let s = value.into();
18        if s.is_empty() {
19            return Err(SafeDiagnosticError::EmptyCode);
20        }
21        if s.len() > Self::MAX_BYTES {
22            return Err(SafeDiagnosticError::CodeTooLong);
23        }
24        if s.chars().any(|c| c.is_control()) {
25            return Err(SafeDiagnosticError::ControlCharacter);
26        }
27        Ok(Self(s))
28    }
29
30    /// Borrow the code.
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36/// Bounded safe diagnostic attached to terminals, cancellations, and events.
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SafeDiagnostic {
39    /// Classification code.
40    pub code: DiagnosticCode,
41    /// Optional pre-redacted detail (never prompts, secrets, or raw bodies).
42    pub message: Option<String>,
43}
44
45impl SafeDiagnostic {
46    /// Construct with optional message truncated to limits.
47    pub fn try_new(
48        code: impl Into<String>,
49        message: Option<impl Into<String>>,
50        max_message_bytes: usize,
51    ) -> Result<Self, SafeDiagnosticError> {
52        let code = DiagnosticCode::try_new(code)?;
53        let message = match message {
54            None => None,
55            Some(m) => {
56                let mut s = m.into();
57                if s.chars().any(|c| c.is_control()) {
58                    return Err(SafeDiagnosticError::ControlCharacter);
59                }
60                if s.len() > max_message_bytes {
61                    s.truncate(max_message_bytes);
62                    // Avoid splitting a UTF-8 scalar.
63                    while !s.is_char_boundary(s.len()) {
64                        s.pop();
65                    }
66                }
67                if s.is_empty() {
68                    None
69                } else {
70                    Some(s)
71                }
72            }
73        };
74        Ok(Self { code, message })
75    }
76
77    /// Construct using default transaction diagnostic byte budget.
78    pub fn try_new_default(
79        code: impl Into<String>,
80        message: Option<impl Into<String>>,
81    ) -> Result<Self, SafeDiagnosticError> {
82        let limits = TransactionLimits::default();
83        Self::try_new(code, message, limits.max_diagnostic_bytes)
84    }
85}
86
87/// Safe diagnostic construction error.
88#[derive(Clone, Debug, Error, PartialEq, Eq)]
89pub enum SafeDiagnosticError {
90    /// Empty code.
91    #[error("diagnostic code must be non-empty")]
92    EmptyCode,
93    /// Code too long.
94    #[error("diagnostic code exceeds maximum length")]
95    CodeTooLong,
96    /// Control characters rejected.
97    #[error("diagnostic must not contain control characters")]
98    ControlCharacter,
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn redacts_by_truncation_not_secret_content() {
107        let long = "x".repeat(2000);
108        let d = SafeDiagnostic::try_new("ok", Some(long), 32).unwrap();
109        assert!(d.message.as_ref().unwrap().len() <= 32);
110    }
111}