Skip to main content

redacted_error/
lib.rs

1//! Stable public error messages with debug-only diagnostic detail.
2//!
3//! `redacted-error` gives applications a small facade for errors that cross
4//! crate, process, API, or protocol boundaries:
5//!
6//! - static public messages go through [`message!`] / [`message_string!`]
7//! - runtime diagnostic details go through [`detail!`], [`detail`], or [`display`]
8//! - backend-facing behavior uses [`ErrorCode`] or [`PublicError`]
9//! - release `Debug` output can delegate to redacted `Display` with
10//!   [`impl_redacted_debug!`]
11//!
12//! The default build uses `obfstr` internally for static public messages. Build
13//! with `default-features = false` to use plain literals instead. The public
14//! macros stay the same either way.
15//!
16//! # Security
17//!
18//! This crate is a leakage-reduction tool, not a confidentiality boundary.
19//!
20//! - The `obfuscate` feature only raises the bar against trivial `strings`-style
21//!   inspection of compiled binaries. It is **not** a defense against a debugger,
22//!   dynamic instrumentation, symbol tables, or any motivated reverse engineer.
23//!   Do not treat obfuscated literals as secret.
24//!
25//! - Diagnostic-detail stripping is gated on `cfg(debug_assertions)`. That cfg
26//!   is on in the `dev` profile and off in the standard `release` profile, but
27//!   Cargo lets users opt back in with `[profile.release] debug-assertions = true`.
28//!   Under that override every [`detail!`] / [`display`] / [`detail`] call leaks
29//!   runtime detail in release builds. Avoid that combination if redaction
30//!   matters.
31//!
32//! - The [`detail!`] macro skips evaluating its format arguments in release.
33//!   The [`detail`] and [`display`] free functions still evaluate (and drop)
34//!   their argument, because the cfg branch lives inside the function body.
35//!   Prefer the macro when the argument has nontrivial cost or side effects.
36
37#![forbid(unsafe_code)]
38#![warn(missing_docs)]
39
40#[cfg(feature = "obfuscate")]
41#[doc(hidden)]
42pub mod __private {
43    pub use obfstr::obfstring;
44}
45
46use std::{borrow::Cow, fmt, ops::Deref};
47
48/// Public-facing message text.
49///
50/// With plain literals this can borrow a static string. With the obfuscating
51/// backend it owns the decoded string, avoiding backend-specific lifetime
52/// behavior in the public API.
53#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct Message(Cow<'static, str>);
55
56impl Message {
57    /// Creates a message from a static string.
58    #[must_use]
59    pub fn from_static(value: &'static str) -> Self {
60        Self(Cow::Borrowed(value))
61    }
62
63    /// Creates a message from an owned string.
64    #[must_use]
65    pub fn from_string(value: String) -> Self {
66        Self(Cow::Owned(value))
67    }
68
69    /// Borrows the message as a string slice.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        self.0.as_ref()
73    }
74
75    /// Converts the message into an owned string.
76    #[must_use]
77    pub fn into_string(self) -> String {
78        self.0.into_owned()
79    }
80}
81
82impl Default for Message {
83    fn default() -> Self {
84        Self(Cow::Borrowed(""))
85    }
86}
87
88impl fmt::Display for Message {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(self.as_str())
91    }
92}
93
94impl fmt::Debug for Message {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        fmt::Debug::fmt(self.as_str(), f)
97    }
98}
99
100impl AsRef<str> for Message {
101    fn as_ref(&self) -> &str {
102        self.as_str()
103    }
104}
105
106impl Deref for Message {
107    type Target = str;
108
109    fn deref(&self) -> &Self::Target {
110        self.as_str()
111    }
112}
113
114impl PartialEq<&str> for Message {
115    fn eq(&self, other: &&str) -> bool {
116        self.as_str() == *other
117    }
118}
119
120impl PartialEq<Message> for &str {
121    fn eq(&self, other: &Message) -> bool {
122        *self == other.as_str()
123    }
124}
125
126impl From<&'static str> for Message {
127    fn from(value: &'static str) -> Self {
128        Self::from_static(value)
129    }
130}
131
132impl From<String> for Message {
133    fn from(value: String) -> Self {
134        Self::from_string(value)
135    }
136}
137
138impl From<Message> for String {
139    fn from(value: Message) -> Self {
140        value.into_string()
141    }
142}
143
144/// Returns a public-facing message for a string literal.
145///
146/// With the default `obfuscate` feature this uses the crate's current
147/// obfuscation backend. With default features disabled this wraps a borrowed
148/// literal.
149///
150/// Accepts only a string literal — not a `const`, `&'static str` binding, or
151/// `concat!()` expansion. The obfuscation backend requires a literal token, and
152/// the macro enforces this for both feature configurations to keep behavior
153/// consistent.
154///
155/// ```
156/// let message = redacted_error::message!("request failed");
157/// assert_eq!(message.as_str(), "request failed");
158/// ```
159#[cfg(feature = "obfuscate")]
160#[macro_export]
161macro_rules! message {
162    ($literal:literal) => {
163        $crate::Message::from_string($crate::__private::obfstring!($literal))
164    };
165}
166
167/// Returns a public-facing message for a string literal.
168///
169/// See [`message!`] for details.
170#[cfg(not(feature = "obfuscate"))]
171#[macro_export]
172macro_rules! message {
173    ($literal:literal) => {
174        $crate::Message::from_static($literal)
175    };
176}
177
178/// Returns an owned public-facing message.
179///
180/// With the default `obfuscate` feature this uses the crate's current
181/// obfuscation backend. With default features disabled this allocates from the
182/// literal.
183///
184/// Accepts only a string literal; see [`message!`] for the rationale.
185///
186/// ```
187/// let owned = redacted_error::message_string!("request failed");
188/// assert_eq!(owned, String::from("request failed"));
189/// ```
190#[cfg(feature = "obfuscate")]
191#[macro_export]
192macro_rules! message_string {
193    ($literal:literal) => {
194        $crate::message!($literal).into_string()
195    };
196}
197
198/// Returns an owned public-facing message.
199///
200/// See [`message_string!`] for details.
201#[cfg(not(feature = "obfuscate"))]
202#[macro_export]
203macro_rules! message_string {
204    ($literal:literal) => {
205        $crate::message!($literal).into_string()
206    };
207}
208
209/// Captures runtime diagnostic detail in debug builds and strips it in release builds.
210///
211/// The argument is always evaluated, then either converted to a `String` (debug)
212/// or dropped (release). Prefer [`detail!`] when constructing the value is
213/// nontrivial or has side effects, because the macro avoids evaluation entirely
214/// in release builds.
215///
216/// In debug builds the result is the input string; in release builds it is empty.
217///
218/// ```
219/// let _ = redacted_error::detail("addr=127.0.0.1:8080");
220/// ```
221#[must_use]
222pub fn detail(value: impl Into<String>) -> String {
223    #[cfg(debug_assertions)]
224    {
225        value.into()
226    }
227
228    #[cfg(not(debug_assertions))]
229    {
230        let _ = value;
231        String::new()
232    }
233}
234
235/// Formats runtime diagnostic detail in debug builds and strips it in release builds.
236///
237/// The argument is always evaluated, then either rendered (debug) or dropped
238/// (release). Prefer [`detail!`] when the argument has nontrivial cost or side
239/// effects.
240///
241/// In debug builds the result is the rendered value; in release builds it is empty.
242///
243/// ```
244/// let _ = redacted_error::display("127.0.0.1:8080");
245/// ```
246#[must_use]
247pub fn display(value: impl std::fmt::Display) -> String {
248    #[cfg(debug_assertions)]
249    {
250        value.to_string()
251    }
252
253    #[cfg(not(debug_assertions))]
254    {
255        let _ = value;
256        String::new()
257    }
258}
259
260/// Formats runtime diagnostic detail in debug builds and strips it in release builds.
261///
262/// In release builds the format arguments are not evaluated — any side effects
263/// in the argument expressions are skipped.
264///
265/// ```
266/// let detail = redacted_error::detail!("addr={}", "127.0.0.1");
267/// # #[cfg(debug_assertions)]
268/// assert_eq!(detail, "addr=127.0.0.1");
269/// ```
270#[macro_export]
271macro_rules! detail {
272    ($($arg:tt)*) => {{
273        #[cfg(debug_assertions)]
274        {
275            format!($($arg)*)
276        }
277
278        #[cfg(not(debug_assertions))]
279        {
280            ::std::string::String::new()
281        }
282    }};
283}
284
285/// Machine-readable code for errors that cross process or API boundaries.
286pub trait ErrorCode {
287    /// Returns a stable code for control flow and public API responses.
288    fn code(&self) -> &'static str;
289}
290
291/// Stable public message for errors that cross process or API boundaries.
292///
293/// This should be safe to expose in release builds. It should not include
294/// paths, addresses, tokens, remote messages, OS errors, SQL errors, config
295/// values, or other runtime detail.
296pub trait PublicError: ErrorCode {
297    /// Returns the stable public-facing message.
298    fn public_message(&self) -> Message;
299}
300
301/// Implements release `Debug` by delegating to `Display`.
302///
303/// Pair this with `#[cfg_attr(debug_assertions, derive(Debug))]` on error types
304/// whose fields may contain sensitive runtime detail. Do **not** use
305/// `#[derive(Debug)]` unconditionally: in release that either conflicts with
306/// the impl emitted here or, if this macro is omitted, leaks every field
307/// through the derived `Debug`.
308///
309/// ```
310/// #[cfg_attr(debug_assertions, derive(Debug))]
311/// struct MyError(String);
312///
313/// impl std::fmt::Display for MyError {
314///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315///         f.write_str("public")
316///     }
317/// }
318///
319/// redacted_error::impl_redacted_debug!(MyError);
320///
321/// let err = MyError("secret".into());
322/// let _ = format!("{err:?}");
323/// ```
324#[macro_export]
325macro_rules! impl_redacted_debug {
326    ($ty:ty) => {
327        #[cfg(not(debug_assertions))]
328        impl ::std::fmt::Debug for $ty {
329            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
330                ::std::fmt::Display::fmt(self, f)
331            }
332        }
333    };
334}
335
336#[cfg(test)]
337mod tests {
338    use super::Message;
339    use std::fmt;
340
341    #[test]
342    fn message_returns_public_literal() {
343        assert_eq!(crate::message!("request failed"), "request failed");
344    }
345
346    #[test]
347    fn message_string_returns_owned_public_literal() {
348        assert_eq!(
349            crate::message_string!("request failed"),
350            String::from("request failed")
351        );
352    }
353
354    #[test]
355    fn message_default_is_empty() {
356        assert_eq!(Message::default(), "");
357    }
358
359    #[test]
360    fn message_from_static_and_string() {
361        let from_static: Message = "literal".into();
362        let from_owned: Message = String::from("literal").into();
363        assert_eq!(from_static, from_owned);
364        assert_eq!(from_static.as_str(), "literal");
365    }
366
367    #[test]
368    fn detail_is_available_only_in_debug_builds() {
369        let value = super::detail("secret");
370
371        #[cfg(debug_assertions)]
372        assert_eq!(value, "secret");
373
374        #[cfg(not(debug_assertions))]
375        assert!(value.is_empty());
376    }
377
378    #[test]
379    fn display_is_available_only_in_debug_builds() {
380        let value = super::display("secret");
381
382        #[cfg(debug_assertions)]
383        assert_eq!(value, "secret");
384
385        #[cfg(not(debug_assertions))]
386        assert!(value.is_empty());
387    }
388
389    #[test]
390    fn detail_macro_is_available_only_in_debug_builds() {
391        let value = crate::detail!("secret {}", 42);
392
393        #[cfg(debug_assertions)]
394        assert_eq!(value, "secret 42");
395
396        #[cfg(not(debug_assertions))]
397        assert!(value.is_empty());
398    }
399
400    #[cfg_attr(debug_assertions, derive(Debug))]
401    #[allow(dead_code)]
402    struct SampleError(String);
403
404    impl fmt::Display for SampleError {
405        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406            #[cfg(debug_assertions)]
407            {
408                write!(f, "{} {}", crate::message!("sample failed:"), self.0)
409            }
410
411            #[cfg(not(debug_assertions))]
412            {
413                write!(f, "{}", crate::message!("sample failed"))
414            }
415        }
416    }
417
418    crate::impl_redacted_debug!(SampleError);
419
420    impl super::ErrorCode for SampleError {
421        fn code(&self) -> &'static str {
422            "sample.failed"
423        }
424    }
425
426    impl super::PublicError for SampleError {
427        fn public_message(&self) -> Message {
428            crate::message!("sample failed")
429        }
430    }
431
432    #[test]
433    fn release_debug_delegates_to_display() {
434        let err = SampleError("secret".to_owned());
435        let debug = format!("{err:?}");
436
437        #[cfg(debug_assertions)]
438        assert!(debug.contains("secret"));
439
440        #[cfg(not(debug_assertions))]
441        assert_eq!(debug, "sample failed");
442    }
443
444    #[test]
445    fn public_error_exposes_stable_code_and_message() {
446        let err = SampleError("secret".to_owned());
447        assert_eq!(super::ErrorCode::code(&err), "sample.failed");
448        assert_eq!(super::PublicError::public_message(&err), "sample failed");
449    }
450}