1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::crypto::SealedRecord;
18use crate::keypair::KeyAlgorithm;
19use crate::secret::SecretValue;
20use crate::sensitivity::Sensitivity;
21use crate::totp::TotpAlgorithm;
22
23pub const SCHEMA_VERSION: u32 = 1;
25
26#[derive(Debug, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "mode", rename_all = "lowercase")]
35pub enum SecretRecord {
36 Literal {
38 value: SecretValue,
40 sensitivity: Sensitivity,
42 #[serde(default)]
49 revealable: bool,
50 environment: String,
52 component: String,
54 key: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 description: Option<String>,
59 created: String,
61 updated: String,
63 },
64 Reference {
66 #[serde(rename = "ref")]
68 reference: String,
69 sensitivity: Sensitivity,
71 #[serde(default)]
73 revealable: bool,
74 environment: String,
76 component: String,
78 key: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 description: Option<String>,
83 created: String,
85 updated: String,
87 },
88 Keypair {
95 algorithm: KeyAlgorithm,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 private: Option<SecretValue>,
101 public: String,
104 sensitivity: Sensitivity,
108 #[serde(default)]
112 revealable: bool,
113 environment: String,
115 component: String,
117 key: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 description: Option<String>,
122 created: String,
124 updated: String,
126 },
127 Totp {
134 seed: SecretValue,
137 #[serde(default)]
139 algorithm: TotpAlgorithm,
140 digits: u8,
142 period: u8,
144 sensitivity: Sensitivity,
147 #[serde(default)]
152 revealable: bool,
153 environment: String,
155 component: String,
157 key: String,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 description: Option<String>,
162 created: String,
164 updated: String,
166 },
167}
168
169impl SecretRecord {
170 pub fn sensitivity(&self) -> Sensitivity {
172 match self {
173 SecretRecord::Literal { sensitivity, .. }
174 | SecretRecord::Reference { sensitivity, .. }
175 | SecretRecord::Keypair { sensitivity, .. }
176 | SecretRecord::Totp { sensitivity, .. } => *sensitivity,
177 }
178 }
179
180 pub fn revealable(&self) -> bool {
185 match self {
186 SecretRecord::Literal { revealable, .. }
187 | SecretRecord::Reference { revealable, .. }
188 | SecretRecord::Keypair { revealable, .. }
189 | SecretRecord::Totp { revealable, .. } => *revealable,
190 }
191 }
192
193 pub fn environment(&self) -> &str {
195 match self {
196 SecretRecord::Literal { environment, .. }
197 | SecretRecord::Reference { environment, .. }
198 | SecretRecord::Keypair { environment, .. }
199 | SecretRecord::Totp { environment, .. } => environment,
200 }
201 }
202
203 pub fn component(&self) -> &str {
205 match self {
206 SecretRecord::Literal { component, .. }
207 | SecretRecord::Reference { component, .. }
208 | SecretRecord::Keypair { component, .. }
209 | SecretRecord::Totp { component, .. } => component,
210 }
211 }
212
213 pub fn key(&self) -> &str {
215 match self {
216 SecretRecord::Literal { key, .. }
217 | SecretRecord::Reference { key, .. }
218 | SecretRecord::Keypair { key, .. }
219 | SecretRecord::Totp { key, .. } => key,
220 }
221 }
222
223 pub fn canonical_path(&self) -> String {
225 format!("{}/{}/{}", self.environment(), self.component(), self.key())
226 }
227
228 pub fn created(&self) -> &str {
230 match self {
231 SecretRecord::Literal { created, .. }
232 | SecretRecord::Reference { created, .. }
233 | SecretRecord::Keypair { created, .. }
234 | SecretRecord::Totp { created, .. } => created,
235 }
236 }
237
238 pub fn updated(&self) -> &str {
240 match self {
241 SecretRecord::Literal { updated, .. }
242 | SecretRecord::Reference { updated, .. }
243 | SecretRecord::Keypair { updated, .. }
244 | SecretRecord::Totp { updated, .. } => updated,
245 }
246 }
247
248 pub fn reference(&self) -> Option<&str> {
252 match self {
253 SecretRecord::Reference { reference, .. } => Some(reference),
254 _ => None,
255 }
256 }
257}
258
259#[derive(Debug, PartialEq, Serialize, Deserialize)]
265pub struct Vault {
266 pub schema_version: u32,
268 pub secrets: BTreeMap<String, SealedRecord>,
270}
271
272impl Default for Vault {
273 fn default() -> Self {
274 Self {
275 schema_version: SCHEMA_VERSION,
276 secrets: BTreeMap::new(),
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn literal() -> SecretRecord {
286 SecretRecord::Literal {
287 value: SecretValue::from("hunter2"),
288 sensitivity: Sensitivity::High,
289 revealable: false,
290 environment: "prod".to_string(),
291 component: "db".to_string(),
292 key: "password".to_string(),
293 description: Some("primary db".to_string()),
294 created: "2026-05-30T00:00:00Z".to_string(),
295 updated: "2026-05-30T00:00:00Z".to_string(),
296 }
297 }
298
299 #[test]
300 fn literal_debug_is_redacted() {
301 let dbg = format!("{:?}", literal());
302 assert!(dbg.contains("REDACTED"));
303 assert!(!dbg.contains("hunter2"));
304 }
305
306 #[test]
307 fn reference_carries_no_value() {
308 let r = SecretRecord::Reference {
309 reference: "azure-kv://corp-kv/db-url".to_string(),
310 sensitivity: Sensitivity::High,
311 revealable: false,
312 environment: "prod".to_string(),
313 component: "db".to_string(),
314 key: "url".to_string(),
315 description: None,
316 created: "2026-05-30T00:00:00Z".to_string(),
317 updated: "2026-05-30T00:00:00Z".to_string(),
318 };
319 let json = serde_json::to_string(&r).unwrap();
321 assert!(json.contains("\"mode\":\"reference\""));
322 assert!(json.contains("azure-kv://corp-kv/db-url"));
323 assert!(!json.contains("\"value\""));
324 }
325
326 #[test]
327 fn revealable_defaults_false_on_legacy_records() {
328 let legacy = r#"{
331 "mode":"literal","value":[104,111,108,97],
332 "sensitivity":"medium","environment":"dev",
333 "component":"app","key":"token",
334 "created":"2026-05-30T00:00:00Z","updated":"2026-05-30T00:00:00Z"
335 }"#;
336 let rec: SecretRecord = serde_json::from_str(legacy).unwrap();
337 assert!(!rec.revealable());
338 assert_eq!(rec.sensitivity(), Sensitivity::Medium);
339 assert_eq!(rec.environment(), "dev");
340 }
341
342 #[test]
343 fn revealable_round_trips_when_set() {
344 let rec = SecretRecord::Literal {
345 value: SecretValue::from("v"),
346 sensitivity: Sensitivity::Medium,
347 revealable: true,
348 environment: "dev".to_string(),
349 component: "app".to_string(),
350 key: "token".to_string(),
351 description: None,
352 created: "2026-05-30T00:00:00Z".to_string(),
353 updated: "2026-05-30T00:00:00Z".to_string(),
354 };
355 let json = serde_json::to_string(&rec).unwrap();
356 assert!(json.contains("\"revealable\":true"));
357 let back: SecretRecord = serde_json::from_str(&json).unwrap();
358 assert!(back.revealable());
359 }
360
361 fn keypair(private: Option<&str>) -> SecretRecord {
362 SecretRecord::Keypair {
363 algorithm: KeyAlgorithm::Ed25519,
364 private: private.map(SecretValue::from),
365 public: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test".to_string(),
366 sensitivity: Sensitivity::High,
367 revealable: false,
368 environment: "prod".to_string(),
369 component: "ssh".to_string(),
370 key: "deploy".to_string(),
371 description: None,
372 created: "2026-06-01T00:00:00Z".to_string(),
373 updated: "2026-06-01T00:00:00Z".to_string(),
374 }
375 }
376
377 #[test]
378 fn keypair_private_debug_is_redacted() {
379 let dbg = format!("{:?}", keypair(Some("PRIVATE-KEY-MATERIAL")));
381 assert!(dbg.contains("REDACTED"));
382 assert!(!dbg.contains("PRIVATE-KEY-MATERIAL"));
383 assert!(dbg.contains("ssh-ed25519"));
385 }
386
387 #[test]
388 fn keypair_accessors_and_public_only_round_trip() {
389 let full = keypair(Some("priv"));
390 assert_eq!(full.sensitivity(), Sensitivity::High);
391 assert_eq!(full.environment(), "prod");
392 assert!(!full.revealable());
393
394 let public_only = keypair(None);
396 let json = serde_json::to_string(&public_only).unwrap();
397 assert!(json.contains("\"mode\":\"keypair\""));
398 assert!(!json.contains("\"private\""));
399 let back: SecretRecord = serde_json::from_str(&json).unwrap();
400 assert_eq!(back, public_only);
401 }
402
403 fn totp() -> SecretRecord {
404 SecretRecord::Totp {
405 seed: SecretValue::from("TOTP-SEED-MATERIAL"),
406 algorithm: TotpAlgorithm::Sha1,
407 digits: 6,
408 period: 30,
409 sensitivity: Sensitivity::High,
410 revealable: false,
411 environment: "prod".to_string(),
412 component: "auth".to_string(),
413 key: "mfa".to_string(),
414 description: None,
415 created: "2026-06-01T00:00:00Z".to_string(),
416 updated: "2026-06-01T00:00:00Z".to_string(),
417 }
418 }
419
420 #[test]
423 fn totp_seed_debug_is_redacted() {
424 let dbg = format!("{:?}", totp());
425 assert!(dbg.contains("REDACTED"));
426 assert!(!dbg.contains("TOTP-SEED-MATERIAL"));
427 assert!(dbg.contains("Sha1"));
428 }
429
430 #[test]
431 fn totp_accessors_and_round_trip() {
432 let t = totp();
433 assert_eq!(t.sensitivity(), Sensitivity::High);
434 assert_eq!(t.environment(), "prod");
435 assert!(!t.revealable());
436 let json = serde_json::to_string(&t).unwrap();
437 assert!(json.contains("\"mode\":\"totp\""));
438 assert!(json.contains("\"digits\":6"));
441 assert!(json.contains("\"period\":30"));
442 let back: SecretRecord = serde_json::from_str(&json).unwrap();
443 assert_eq!(back, t);
444 }
445
446 #[test]
447 fn default_vault_is_empty_and_versioned() {
448 let v = Vault::default();
449 assert_eq!(v.schema_version, SCHEMA_VERSION);
450 assert!(v.secrets.is_empty());
451 }
452}