Skip to main content

use_secret/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3#![allow(clippy::module_name_repetitions)]
4
5use core::{fmt, str::FromStr};
6use std::error::Error;
7
8/// Error returned when secret text metadata is invalid.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum SecretTextError {
11    Empty,
12}
13
14impl fmt::Display for SecretTextError {
15    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
16        formatter.write_str("secret metadata text cannot be empty")
17    }
18}
19
20impl Error for SecretTextError {}
21
22/// Error returned when a secret label cannot be parsed.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum SecretParseError {
25    Empty,
26    Unknown,
27}
28
29impl fmt::Display for SecretParseError {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Empty => formatter.write_str("secret label cannot be empty"),
33            Self::Unknown => formatter.write_str("unknown secret label"),
34        }
35    }
36}
37
38impl Error for SecretParseError {}
39
40macro_rules! text_newtype {
41    ($name:ident, $redacted_debug:expr) => {
42        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
43        pub struct $name(String);
44
45        impl $name {
46            /// Creates non-empty secret text metadata.
47            pub fn new(input: impl AsRef<str>) -> Result<Self, SecretTextError> {
48                let trimmed = input.as_ref().trim();
49                if trimmed.is_empty() {
50                    Err(SecretTextError::Empty)
51                } else {
52                    Ok(Self(trimmed.to_owned()))
53                }
54            }
55
56            /// Returns the stored text.
57            #[must_use]
58            pub fn as_str(&self) -> &str {
59                &self.0
60            }
61        }
62
63        impl fmt::Debug for $name {
64            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65                if $redacted_debug {
66                    formatter.write_str(concat!(stringify!($name), "(\"<redacted>\")"))
67                } else {
68                    formatter
69                        .debug_tuple(stringify!($name))
70                        .field(&self.0)
71                        .finish()
72                }
73            }
74        }
75
76        impl fmt::Display for $name {
77            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78                formatter.write_str(self.as_str())
79            }
80        }
81
82        impl FromStr for $name {
83            type Err = SecretTextError;
84
85            fn from_str(input: &str) -> Result<Self, Self::Err> {
86                Self::new(input)
87            }
88        }
89
90        impl TryFrom<&str> for $name {
91            type Error = SecretTextError;
92
93            fn try_from(value: &str) -> Result<Self, Self::Error> {
94                Self::new(value)
95            }
96        }
97    };
98}
99
100macro_rules! label_enum {
101    ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
102        impl $name {
103            /// Returns the stable label.
104            #[must_use]
105            pub const fn as_str(self) -> &'static str {
106                match self {
107                    $(Self::$variant => $label,)+
108                }
109            }
110        }
111
112        impl fmt::Display for $name {
113            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114                formatter.write_str(self.as_str())
115            }
116        }
117
118        impl FromStr for $name {
119            type Err = SecretParseError;
120
121            fn from_str(input: &str) -> Result<Self, Self::Err> {
122                let trimmed = input.trim();
123                if trimmed.is_empty() {
124                    return Err(SecretParseError::Empty);
125                }
126                let normalized = trimmed.to_ascii_lowercase();
127                match normalized.as_str() {
128                    $($label => Ok(Self::$variant),)+
129                    _ => Err(SecretParseError::Unknown),
130                }
131            }
132        }
133    };
134}
135
136text_newtype!(SecretName, false);
137text_newtype!(SecretReference, true);
138
139/// Secret kind labels.
140#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub enum SecretKind {
142    ApiKey,
143    AccessToken,
144    RefreshToken,
145    Password,
146    ClientSecret,
147    PrivateKey,
148    Certificate,
149    WebhookSecret,
150    SigningSecret,
151    DatabaseUrl,
152    ConnectionString,
153    SshKey,
154    Unknown,
155}
156
157label_enum!(SecretKind {
158    ApiKey => "api-key",
159    AccessToken => "access-token",
160    RefreshToken => "refresh-token",
161    Password => "password",
162    ClientSecret => "client-secret",
163    PrivateKey => "private-key",
164    Certificate => "certificate",
165    WebhookSecret => "webhook-secret",
166    SigningSecret => "signing-secret",
167    DatabaseUrl => "database-url",
168    ConnectionString => "connection-string",
169    SshKey => "ssh-key",
170    Unknown => "unknown",
171});
172
173/// Secret provider labels.
174#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
175pub enum SecretProvider {
176    Environment,
177    File,
178    Vault,
179    CloudSecretManager,
180    KubernetesSecret,
181    CiSecretStore,
182    LocalConfig,
183    Unknown,
184}
185
186label_enum!(SecretProvider {
187    Environment => "environment",
188    File => "file",
189    Vault => "vault",
190    CloudSecretManager => "cloud-secret-manager",
191    KubernetesSecret => "kubernetes-secret",
192    CiSecretStore => "ci-secret-store",
193    LocalConfig => "local-config",
194    Unknown => "unknown",
195});
196
197/// Secret scope labels.
198#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
199pub enum SecretScope {
200    Local,
201    Project,
202    Organization,
203    Environment,
204    Global,
205}
206
207label_enum!(SecretScope {
208    Local => "local",
209    Project => "project",
210    Organization => "organization",
211    Environment => "environment",
212    Global => "global",
213});
214
215/// Secret sensitivity labels.
216#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
217pub enum SecretSensitivity {
218    Low,
219    Medium,
220    High,
221    Critical,
222}
223
224label_enum!(SecretSensitivity {
225    Low => "low",
226    Medium => "medium",
227    High => "high",
228    Critical => "critical",
229});
230
231/// Secret rotation status labels.
232#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
233pub enum SecretRotationStatus {
234    Unknown,
235    Current,
236    RotationDue,
237    Rotating,
238    Revoked,
239    Expired,
240}
241
242label_enum!(SecretRotationStatus {
243    Unknown => "unknown",
244    Current => "current",
245    RotationDue => "rotation-due",
246    Rotating => "rotating",
247    Revoked => "revoked",
248    Expired => "expired",
249});
250
251/// Secret redaction strategy labels.
252#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
253pub enum SecretRedaction {
254    All,
255    KeepLast(usize),
256    KeepPrefixSuffix { prefix: usize, suffix: usize },
257}
258
259impl SecretRedaction {
260    /// Applies this redaction strategy to a value.
261    #[must_use]
262    pub fn apply(self, value: &str) -> String {
263        match self {
264            Self::All => mask_all(value),
265            Self::KeepLast(count) => mask_keep_last(value, count),
266            Self::KeepPrefixSuffix { prefix, suffix } => {
267                mask_keep_prefix_suffix(value, prefix, suffix)
268            }
269        }
270    }
271}
272
273/// A wrapper that never exposes its value through `Debug` or `Display`.
274#[derive(Clone, Eq, PartialEq)]
275pub struct MaskedSecret(String);
276
277impl MaskedSecret {
278    /// Stores a secret value for explicit masking workflows.
279    #[must_use]
280    pub fn new(value: impl Into<String>) -> Self {
281        Self(value.into())
282    }
283
284    /// Returns the wrapped secret value by reference.
285    #[must_use]
286    pub fn expose_secret(&self) -> &str {
287        &self.0
288    }
289
290    /// Returns a fully masked representation.
291    #[must_use]
292    pub fn redacted(&self) -> String {
293        mask_all(&self.0)
294    }
295}
296
297impl fmt::Debug for MaskedSecret {
298    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
299        formatter.write_str("MaskedSecret(\"<redacted>\")")
300    }
301}
302
303impl fmt::Display for MaskedSecret {
304    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
305        formatter.write_str("<redacted>")
306    }
307}
308
309/// Masks every character in a value.
310#[must_use]
311pub fn mask_all(value: &str) -> String {
312    "*".repeat(value.chars().count())
313}
314
315/// Masks all but the last `count` characters in a value.
316#[must_use]
317pub fn mask_keep_last(value: &str, count: usize) -> String {
318    let chars: Vec<char> = value.chars().collect();
319    if count >= chars.len() {
320        return value.to_owned();
321    }
322    let masked = "*".repeat(chars.len() - count);
323    let suffix: String = chars[chars.len() - count..].iter().collect();
324    format!("{masked}{suffix}")
325}
326
327/// Masks the middle while keeping a prefix and suffix.
328#[must_use]
329pub fn mask_keep_prefix_suffix(value: &str, prefix: usize, suffix: usize) -> String {
330    let chars: Vec<char> = value.chars().collect();
331    if prefix + suffix >= chars.len() {
332        return value.to_owned();
333    }
334    let prefix_text: String = chars[..prefix].iter().collect();
335    let suffix_text: String = chars[chars.len() - suffix..].iter().collect();
336    let masked = "*".repeat(chars.len() - prefix - suffix);
337    format!("{prefix_text}{masked}{suffix_text}")
338}
339
340#[cfg(test)]
341mod tests {
342    use super::{
343        MaskedSecret, SecretKind, SecretProvider, SecretRedaction, SecretReference, mask_all,
344        mask_keep_last, mask_keep_prefix_suffix,
345    };
346
347    #[test]
348    fn masks_secret_values() {
349        assert_eq!(mask_all("abcd"), "****");
350        assert_eq!(mask_keep_last("abcdef", 2), "****ef");
351        assert_eq!(mask_keep_prefix_suffix("abcdefgh", 2, 2), "ab****gh");
352        assert_eq!(SecretRedaction::KeepLast(3).apply("abcdef"), "***def");
353    }
354
355    #[test]
356    fn redacts_debug_for_secret_wrappers() {
357        let reference = SecretReference::new("prod/db/password").expect("reference");
358        let secret = MaskedSecret::new("very-secret-token");
359
360        assert_eq!(format!("{reference:?}"), "SecretReference(\"<redacted>\")");
361        assert_eq!(format!("{secret:?}"), "MaskedSecret(\"<redacted>\")");
362        assert!(!format!("{secret:?}").contains("very-secret-token"));
363        assert_eq!(secret.to_string(), "<redacted>");
364    }
365
366    #[test]
367    fn parses_and_displays_labels() {
368        assert_eq!(
369            "api-key".parse::<SecretKind>().expect("kind"),
370            SecretKind::ApiKey
371        );
372        assert_eq!(
373            SecretProvider::KubernetesSecret.to_string(),
374            "kubernetes-secret"
375        );
376    }
377}