redaction helps you keep sensitive values (tokens, secrets, PII) out of places they don't belong by:
- Deriving
Sensitiveon your types with#[derive(Sensitive)] - Marking sensitive fields with
#[sensitive]or#[sensitive(Classification)] - Calling
.redact()to produce a copy that is safe to log or serialize - Generating
Debugoutput that prints"[REDACTED]"for sensitive fields (independent of policies)
- Classifications are domain concepts: marker types like
Secret,Token, or your ownInternalIdrepresent what kind of data a field contains. - Policies belong in the application layer: policies are attached to classification types (
impl RedactionPolicy for MyClassification) in the layer where you define "what is safe to expose", typically close to logging/serialization boundaries. - Sinks are optional adapters: integrations like
sloglive behind feature flags; your domain types don't depend on a logging framework. - Layering is optional: you can put classifications, policies, and redaction calls in a single crate if you prefer. The library supports both "clean architecture" layering and simple, pragmatic project layouts.
Sensitive data ends up places it shouldn't:
- Logging: Structured logs capture request/response bodies containing passwords, tokens, PII
- Serialization: API responses, database exports, and message queues include fields that should be hidden
- Error reporting: Stack traces and error contexts expose sensitive state
- Debug output:
#[derive(Debug)]prints everything, including secrets
Once sensitive data reaches these systems, it is often:
- Stored long-term (retention policies, backups)
- Indexed and searchable
- Replicated across environments
- Visible to anyone with access to logs/telemetry
#[derive(Debug, serde::Serialize)]
struct LoginRequest {
username: String,
password: String,
}
let request = LoginRequest {
username: "alice".into(),
password: "hunter2".into(),
};
// Debug output exposes the password
println!("{:?}", request);
// → LoginRequest { username: "alice", password: "hunter2" }
// Serialization also exposes the password
let json = serde_json::to_string(&request).unwrap();
// → {"username":"alice","password":"hunter2"}
//
// (This example uses `serde_json` to make the risk concrete. The same problem
// exists with any serializer that includes `password`.)Mark sensitive fields explicitly. This crate provides:
- Safe Debug: sensitive fields print as
[REDACTED] - Explicit redaction: call
.redact()to get a copy safe for serialization and logging - Policy control: choose how each classification is redacted (full, keep, mask)
- External type support: types you don't control (like
chrono::DateTime) just work
use redaction::{Redactable, Secret, Token, Sensitive};
#[derive(Clone, Sensitive)]
struct LoginRequest {
username: String,
#[sensitive(Secret)]
password: String,
#[sensitive(Token)]
api_key: String,
}
let request = LoginRequest {
username: "alice".into(),
password: "hunter2".into(),
api_key: "tok_live_abcdef".into(),
};
// Get a redacted copy for serialization, APIs, and logging
let safe = request.redact();
assert_eq!(safe.password, "[REDACTED]"); // Secret: fully redacted
assert_eq!(safe.api_key, "***********cdef"); // Token: only last 4 visible
assert_eq!(safe.username, "alice"); // Not sensitive: unchanged
// Debug output is also safe, but it does NOT apply policies:
// it always prints `"[REDACTED]"` for `#[sensitive(...)]` fields.
println!("{:?}", request);
// → LoginRequest { username: "alice", password: "[REDACTED]", api_key: "[REDACTED]" }[dependencies]
redaction = "0.1"- Add
#[derive(Clone, Sensitive)]to your type - Mark sensitive fields with
#[sensitive]or#[sensitive(Classification)] - Call
.redact()before you log, serialize, return, or persist the value
use redaction::{Redactable, Secret, Token, Sensitive};
#[derive(Clone, Sensitive)]
struct ApiCredentials {
#[sensitive(Secret)]
password: String,
#[sensitive(Token)]
api_key: String,
user_id: String, // not sensitive, passed through unchanged
}
let creds = ApiCredentials {
password: "super_secret".into(),
api_key: "tok_live_abcdef".into(),
user_id: "user_42".into(),
};
let redacted = creds.redact();
assert_eq!(redacted.password, "[REDACTED]"); // Secret → fully redacted
assert_eq!(redacted.api_key, "***********cdef"); // Token → only last 4 visible
assert_eq!(redacted.user_id, "user_42"); // unchangedThe #[sensitive(...)] attribute controls how each field is handled:
| Attribute | Use For | Behavior |
|---|---|---|
| (none) | Non-sensitive fields, external types | Pass through unchanged |
#[sensitive] |
Scalars OR nested Sensitive types |
Walk containers, or redact scalars to default |
#[sensitive(Class)] |
String-like leaf values | Apply classification's redaction policy |
Classifications are for string-like leaf values; the field type must implement SensitiveValue
and Classifiable.
use redaction::{Redactable, Secret, Sensitive};
#[derive(Clone, Sensitive)]
struct Address {
#[sensitive(Secret)]
street: String,
city: String, // Not sensitive
}
#[derive(Clone, Sensitive)]
struct User {
#[sensitive(Secret)]
ssn: String, // Leaf value: apply Secret policy
#[sensitive]
address: Address, // Nested struct: walk into it
#[sensitive]
age: i32, // Scalar: redact to default (0)
created_at: DateTime<Utc>, // External type: passes through unchanged
balance: Decimal, // External type: passes through unchanged
}Fields without #[sensitive] pass through unchanged. This means external types like chrono::DateTime,
rust_decimal::Decimal, uuid::Uuid, or any type you don't control work automatically. Do not
add #[sensitive] unless the type implements SensitiveType.
use chrono::{DateTime, Utc};
#[derive(Clone, Sensitive)]
struct Transaction {
#[sensitive(Secret)]
account_number: String,
// No annotation needed - external types pass through!
timestamp: DateTime<Utc>,
amount: Decimal,
id: Uuid,
}When a field's type also derives Sensitive, use #[sensitive] to walk into it:
#[derive(Clone, Sensitive)]
struct Credentials {
#[sensitive(Secret)]
password: String,
}
#[derive(Clone, Sensitive)]
struct User {
#[sensitive] // Walk into Credentials
creds: Credentials,
}Important: Without #[sensitive], nested structs pass through unchanged (even if they derive Sensitive). This is by design - you explicitly choose what to redact.
Classifications work on nested wrapper types like Option<Vec<String>> automatically:
#[derive(Clone, Sensitive)]
struct User {
#[sensitive(Pii)]
emails: Option<Vec<String>>, // Works! Recursively applies Pii to each String
#[sensitive(Secret)]
backup_codes: Vec<Option<String>>, // Also works!
#[sensitive(Secret)]
metadata: HashMap<String, Vec<String>>, // Maps work too!
}The classification is applied recursively through any nesting depth of:
Option<T>Vec<T>Box<T>HashMap<K, V>(values only)BTreeMap<K, V>(values only)HashSet<T>/BTreeSet<T>Result<T, E>
Each classification has a default redaction policy. Use the one that matches your data:
| Classification | Use for | Example output |
|---|---|---|
Secret |
Passwords, private keys | [REDACTED] |
Token |
API keys, bearer tokens | …abcd (last 4) |
Email |
Email addresses | jo… (first 2) |
CreditCard |
Card numbers (PANs) | …1234 (last 4) |
Pii |
Generic PII | …_doe (last 4) |
PhoneNumber |
Phone numbers | …12 (last 2) |
NationalId |
SSN, passport numbers | …6789 (last 4) |
AccountId |
Account identifiers | …abcd (last 4) |
SessionId |
Session tokens | …wxyz (last 4) |
IpAddress |
IP addresses | …1.1 (last 4 chars) |
DateOfBirth |
Birth dates | [REDACTED] |
BlockchainAddress |
Wallet addresses | …abc123 (last 6) |
When built-in classifications don't fit, create your own:
use redaction::{Classification, RedactionPolicy, TextRedactionPolicy};
#[derive(Clone, Copy)]
struct InternalId;
impl Classification for InternalId {}
impl RedactionPolicy for InternalId {
fn policy() -> TextRedactionPolicy {
TextRedactionPolicy::keep_last(2) // Show only last 2 characters
}
}Clean architecture note:
- Put the classification type (
InternalId) in your domain crate/module. - Put the policy implementation (
impl RedactionPolicy for InternalId) in your application or infrastructure layer (where you define what is safe to expose and where logging/serialization happens).
Then use it like any built-in:
#[derive(Clone, Sensitive)]
struct Record {
#[sensitive(InternalId)]
id: String,
}Three policy types control how values are transformed:
- Full: replace the entire value with a placeholder
TextRedactionPolicy::default_full() // → "[REDACTED]"
TextRedactionPolicy::full_with("<hidden>") // → "<hidden>"- Keep: keep specified characters visible, mask everything else
TextRedactionPolicy::keep_first(4) // "secret123" → "secr*****"
TextRedactionPolicy::keep_last(4) // "secret123" → "*****t123"
TextRedactionPolicy::keep_with(KeepConfig::both(2, 2)) // "secret" → "se**et"- Mask: mask specified characters, keep the rest visible
TextRedactionPolicy::mask_first(4) // "secret123" → "****et123"
TextRedactionPolicy::mask_last(4) // "secret123" → "secre****"With the slog feature, Sensitive types automatically redact when logged as
structured JSON:
[dependencies]
redaction = { version = "0.1", features = ["slog"] }#[derive(Clone, Sensitive)]
#[cfg_attr(feature = "slog", derive(serde::Serialize))]
struct LoginEvent {
#[sensitive(Secret)]
password: String,
username: String,
}
// Redacts automatically (no explicit .redact() needed)
slog::info!(logger, "login"; "event" => event);Structured JSON requirements (why they exist):
- Type must implement
Cloneso the redacted JSON payload can be built without consuming the original value. - Type must implement
serde::Serializebecause structured logging emits JSON derived from the redacted copy. - The slog adapter uses
IntoRedactedJson, which is auto-implemented forRedactable + Serialize.
If those bounds are too strict, use SensitiveError instead to log a redacted
string without requiring Serialize.
For types that cannot or should not derive Serialize, use SensitiveError. It
emits the same redacted Debug output, but logs as a string using a redacted
display template rather than JSON:
use redaction::{Secret, SensitiveError};
#[derive(SensitiveError)]
enum LoginError {
#[error("invalid login for {username} {password}")]
InvalidCredentials {
username: String,
#[sensitive(Secret)]
password: String,
},
Io(std::io::Error), // not serializable
}
slog::error!(logger, "login failed"; "error" => err);This path does not require Serialize on the type. SensitiveError generates a
RedactedDisplay implementation used by the slog adapter, so your error’s
normal Display (from thiserror or displaydoc) can remain unchanged while
logs still use a redacted string.
Template rules and bounds:
- Template required:
#[error("...")]or doc comments (derive fails otherwise) - Pass-through
{field}usesDisplay;{field:?}usesDebug #[sensitive(Classification)]in template:Clone + Display(orDebugfor:?)#[sensitive]scalars use defaults (no extra bounds)#[sensitive]non-scalars in template must deriveSensitiveError
classification(default): built-in classification typespolicy(default): redaction policies and.redact()slog: structured logging adaptertesting: unredactedDebugoutput in tests
- Traversal:
#[sensitive]on non-scalars requiresSensitiveType - Classification:
#[sensitive(Classification)]requiresClassifiable - Debug: fields shown in
Debugoutput requireDebug slogJSON (Sensitive): the type itself requiresClone + Serialize + IntoRedactedJsonslogstring (SensitiveError): see template rules above (bounds are template-dependent)
The library uses these core traits, organized by layer:
Domain Layer (what is sensitive):
| Trait | Purpose | Implemented By |
|---|---|---|
SensitiveType |
Types that contain sensitive data | Structs/enums deriving Sensitive |
SensitiveValue |
Types that are sensitive data | String, Cow<str>, custom newtypes |
Policy Layer (how to redact):
| Trait | Purpose | Implemented By |
|---|---|---|
RedactionPolicy |
Maps classification → redaction strategy | Your custom classifications |
TextRedactionPolicy |
Concrete string transformations | Built-in (Full, Keep, Mask) |
Application Layer (redaction machinery):
| Trait | Purpose | Implemented By |
|---|---|---|
Classifiable |
Types that can have classifications applied | String, wrappers (Option, Vec, etc.) |
Redactable |
User-facing .redact() method |
Auto-implemented for SensitiveType |
RedactionMapper |
Internal traversal machinery | #[doc(hidden)] |
- Use
#[sensitive]on fields ofSensitiveTypetypes (to walk into them) - Use
#[sensitive(Classification)]on fields ofClassifiabletypes (supports nested wrappers)
String-like (SensitiveValue): Use #[sensitive(Classification)]:
StringCow<'_, str>(redaction returns an owned value)
Scalars: Use bare #[sensitive] (no classification):
- Integers:
i8-i128,u8-u128,isize,usize - Floats:
f32,f64 bool→ redacts tofalsechar→ redacts to'X'
Containers (SensitiveType): Use #[sensitive] to walk, or omit for pass-through:
Option<T>: redacts inner value if presentVec<T>: redacts all elementsBox<T>: redacts inner valueHashMap<K, V>,BTreeMap<K, V>: redacts values only (keys unchanged)HashSet<T>,BTreeSet<T>: redacts elementsResult<T, E>: redacts bothOkandErrsides
External types: No annotation needed (pass through):
chrono::DateTime<Tz>,rust_decimal::Decimal,uuid::Uuid, etc.- Any type that doesn't implement
SensitiveType
PhantomData: Automatically handled (pass through, no trait bounds added).
The library provides helpful error messages for common mistakes:
Using a classification on a struct:
error[E0277]: `Address` is not a `SensitiveValue`
= note: classifications like `#[sensitive(Secret)]` are for leaf values (String, etc.)
= note: if `Address` is a struct that derives `Sensitive`, use `#[sensitive]` instead
Using #[sensitive] on an external type:
error[E0277]: `DateTime<Utc>` does not implement `SensitiveType`
= note: use `#[derive(Sensitive)]` on the type definition
= note: or remove the #[sensitive] attribute to pass through unchanged
- Empty string (
""):- Keep/Mask: returns
"" - Full: returns the placeholder (default:
"[REDACTED]")
- Keep/Mask: returns
- Keep policies (
keep_first,keep_last,KeepConfig::both) operate on Unicode scalar values:- If
visible_prefix + visible_suffix >= length, the value is returned unchanged
- If
- Mask policies (
mask_first,mask_last,MaskConfig::both) operate on Unicode scalar values:- If
mask_prefix + mask_suffix >= length, the entire value is masked
- If
- Length: keep/mask policies preserve the input length (full does not)
Scalar type aliases: Only bare primitive names (i32, bool) are recognized as scalars. Type aliases like type MyInt = i32 or qualified paths like std::primitive::i32 are treated as non-scalars and require #[sensitive(Classification)] or pass-through.
Boxed trait objects: The derive detects only the simple syntax Box<dyn Trait> and calls redact_boxed. It does not match std::boxed::Box<dyn Trait> or type aliases. The trait object must implement RedactableBoxed.
Foreign string types: For string-like types from other crates, wrap in a newtype:
use redaction::SensitiveValue;
struct WrappedId(external_crate::Id);
impl SensitiveValue for WrappedId {
fn as_str(&self) -> &str { self.0.as_str() }
fn from_redacted(s: String) -> Self { WrappedId(external_crate::Id::from(s)) }
}Map keys: Never redacted. Move sensitive data into values.
Debug vs redact(): The derived Debug formats the type normally, but replaces the values of #[sensitive(...)] fields with the string "[REDACTED]". It does not apply the field's policy. Use .redact() when you need policy-based output.
Testing: Enable the testing feature to get unredacted Debug output in tests:
[dev-dependencies]
redaction = { version = "0.1", features = ["testing"] }- Length preservation: Keep/Mask policies preserve input length, which can leak information about value size. Use Full redaction for maximum privacy.
- Timing: Redaction is not constant-time. Do not use in cryptographic contexts.
- Memory: Original values may persist in memory until overwritten. Consider secure memory handling for highly sensitive data.
To set up git hooks for pre-commit checks (fmt, clippy, tests):
git config core.hooksPath .githooksLicensed under the MIT license (LICENSE.md or opensource.org/licenses/MIT).