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}