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#[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#[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 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 #[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 #[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#[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#[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#[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#[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#[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#[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 #[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#[derive(Clone, Eq, PartialEq)]
275pub struct MaskedSecret(String);
276
277impl MaskedSecret {
278 #[must_use]
280 pub fn new(value: impl Into<String>) -> Self {
281 Self(value.into())
282 }
283
284 #[must_use]
286 pub fn expose_secret(&self) -> &str {
287 &self.0
288 }
289
290 #[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#[must_use]
311pub fn mask_all(value: &str) -> String {
312 "*".repeat(value.chars().count())
313}
314
315#[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#[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}