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    ///
289    /// Use [`message!`] to keep the code literal out of the binary, or
290    /// [`Message::from_static`] for a plain literal. Comparison against `&str`
291    /// works directly via [`Message`]'s `PartialEq<&str>`:
292    ///
293    /// ```
294    /// use redacted_error::{ErrorCode, Message};
295    /// # struct E;
296    /// # impl ErrorCode for E {
297    /// #     fn code(&self) -> Message { redacted_error::message!("x.y") }
298    /// # }
299    /// # let err = E;
300    /// if err.code() == "x.y" { /* ... */ }
301    /// ```
302    fn code(&self) -> Message;
303}
304
305/// Stable public message for errors that cross process or API boundaries.
306///
307/// This should be safe to expose in release builds. It should not include
308/// paths, addresses, tokens, remote messages, OS errors, SQL errors, config
309/// values, or other runtime detail.
310pub trait PublicError: ErrorCode {
311    /// Returns the stable public-facing message.
312    fn public_message(&self) -> Message;
313}
314
315/// Implements release `Debug` by delegating to `Display`.
316///
317/// Pair this with `#[cfg_attr(debug_assertions, derive(Debug))]` on error types
318/// whose fields may contain sensitive runtime detail. Do **not** use
319/// `#[derive(Debug)]` unconditionally: in release that either conflicts with
320/// the impl emitted here or, if this macro is omitted, leaks every field
321/// through the derived `Debug`.
322///
323/// ```
324/// #[cfg_attr(debug_assertions, derive(Debug))]
325/// struct MyError(String);
326///
327/// impl std::fmt::Display for MyError {
328///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329///         f.write_str("public")
330///     }
331/// }
332///
333/// redacted_error::impl_redacted_debug!(MyError);
334///
335/// let err = MyError("secret".into());
336/// let _ = format!("{err:?}");
337/// ```
338#[macro_export]
339macro_rules! impl_redacted_debug {
340    ($ty:ty) => {
341        #[cfg(not(debug_assertions))]
342        impl ::std::fmt::Debug for $ty {
343            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
344                ::std::fmt::Display::fmt(self, f)
345            }
346        }
347    };
348}
349
350#[cfg(test)]
351mod tests {
352    use super::Message;
353    use std::fmt;
354
355    #[test]
356    fn message_returns_public_literal() {
357        assert_eq!(crate::message!("request failed"), "request failed");
358    }
359
360    #[test]
361    fn message_string_returns_owned_public_literal() {
362        assert_eq!(
363            crate::message_string!("request failed"),
364            String::from("request failed")
365        );
366    }
367
368    #[test]
369    fn message_default_is_empty() {
370        assert_eq!(Message::default(), "");
371    }
372
373    #[test]
374    fn message_from_static_and_string() {
375        let from_static: Message = "literal".into();
376        let from_owned: Message = String::from("literal").into();
377        assert_eq!(from_static, from_owned);
378        assert_eq!(from_static.as_str(), "literal");
379    }
380
381    #[test]
382    fn detail_is_available_only_in_debug_builds() {
383        let value = super::detail("secret");
384
385        #[cfg(debug_assertions)]
386        assert_eq!(value, "secret");
387
388        #[cfg(not(debug_assertions))]
389        assert!(value.is_empty());
390    }
391
392    #[test]
393    fn display_is_available_only_in_debug_builds() {
394        let value = super::display("secret");
395
396        #[cfg(debug_assertions)]
397        assert_eq!(value, "secret");
398
399        #[cfg(not(debug_assertions))]
400        assert!(value.is_empty());
401    }
402
403    #[test]
404    fn detail_macro_is_available_only_in_debug_builds() {
405        let value = crate::detail!("secret {}", 42);
406
407        #[cfg(debug_assertions)]
408        assert_eq!(value, "secret 42");
409
410        #[cfg(not(debug_assertions))]
411        assert!(value.is_empty());
412    }
413
414    #[cfg_attr(debug_assertions, derive(Debug))]
415    #[allow(dead_code)]
416    struct SampleError(String);
417
418    impl fmt::Display for SampleError {
419        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420            #[cfg(debug_assertions)]
421            {
422                write!(f, "{} {}", crate::message!("sample failed:"), self.0)
423            }
424
425            #[cfg(not(debug_assertions))]
426            {
427                write!(f, "{}", crate::message!("sample failed"))
428            }
429        }
430    }
431
432    crate::impl_redacted_debug!(SampleError);
433
434    impl super::ErrorCode for SampleError {
435        fn code(&self) -> Message {
436            crate::message!("sample.failed")
437        }
438    }
439
440    impl super::PublicError for SampleError {
441        fn public_message(&self) -> Message {
442            crate::message!("sample failed")
443        }
444    }
445
446    #[test]
447    fn release_debug_delegates_to_display() {
448        let err = SampleError("secret".to_owned());
449        let debug = format!("{err:?}");
450
451        #[cfg(debug_assertions)]
452        assert!(debug.contains("secret"));
453
454        #[cfg(not(debug_assertions))]
455        assert_eq!(debug, "sample failed");
456    }
457
458    #[test]
459    fn public_error_exposes_stable_code_and_message() {
460        let err = SampleError("secret".to_owned());
461        assert_eq!(super::ErrorCode::code(&err), "sample.failed");
462        assert_eq!(super::PublicError::public_message(&err), "sample failed");
463    }
464}