1use std::path::Path;
17
18use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
19use serde::{Deserialize, Serialize};
20
21use crate::crypto::{KEY_LEN, SealedRecord, open_bytes, seal_bytes};
22use crate::error::CoreError;
23use crate::fingerprint::fingerprint;
24use crate::record::SecretRecord;
25use crate::sensitivity::Sensitivity;
26use crate::store;
27
28pub const INDEX_FILE: &str = "index.redb";
30
31const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
33const GEN: TableDefinition<&str, u64> = TableDefinition::new("generation");
35const GEN_KEY: &str = "g";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum RecordMode {
41 Literal,
43 Reference,
45 Keypair,
48 Totp,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct IndexEntry {
55 pub id: String,
57 pub environment: String,
59 pub component: String,
61 pub key: String,
63 pub sensitivity: Sensitivity,
65 pub mode: RecordMode,
67 pub ref_scheme: Option<String>,
69 pub fingerprint: Option<String>,
71 pub created: String,
73 pub updated: String,
75 pub origin: String,
77 pub record_path: String,
79}
80
81impl IndexEntry {
82 pub fn from_record(id: &str, record: &SecretRecord, origin: &str, record_path: &str) -> Self {
85 let (sensitivity, environment, component, key, created, updated) = match record {
87 SecretRecord::Literal {
88 sensitivity,
89 environment,
90 component,
91 key,
92 created,
93 updated,
94 ..
95 }
96 | SecretRecord::Reference {
97 sensitivity,
98 environment,
99 component,
100 key,
101 created,
102 updated,
103 ..
104 }
105 | SecretRecord::Keypair {
106 sensitivity,
107 environment,
108 component,
109 key,
110 created,
111 updated,
112 ..
113 }
114 | SecretRecord::Totp {
115 sensitivity,
116 environment,
117 component,
118 key,
119 created,
120 updated,
121 ..
122 } => (sensitivity, environment, component, key, created, updated),
123 };
124 let (mode, ref_scheme, fingerprint) = match record {
129 SecretRecord::Literal { value, .. } => {
130 (RecordMode::Literal, None, Some(fingerprint(value.expose())))
131 }
132 SecretRecord::Reference { reference, .. } => {
133 (RecordMode::Reference, ref_scheme(reference), None)
134 }
135 SecretRecord::Keypair { public, .. } => (
136 RecordMode::Keypair,
137 None,
138 Some(fingerprint(public.as_bytes())),
139 ),
140 SecretRecord::Totp {
144 algorithm,
145 digits,
146 period,
147 ..
148 } => (
149 RecordMode::Totp,
150 None,
151 Some(fingerprint(
152 format!("totp:{}:{digits}:{period}", algorithm.as_str()).as_bytes(),
153 )),
154 ),
155 };
156 IndexEntry {
157 id: id.to_string(),
158 environment: environment.clone(),
159 component: component.clone(),
160 key: key.clone(),
161 sensitivity: *sensitivity,
162 mode,
163 ref_scheme,
164 fingerprint,
165 created: created.clone(),
166 updated: updated.clone(),
167 origin: origin.to_string(),
168 record_path: record_path.to_string(),
169 }
170 }
171
172 pub fn coordinate(&self) -> String {
175 format!("{}/{}/{}", self.environment, self.component, self.key)
176 }
177}
178
179fn ref_scheme(reference: &str) -> Option<String> {
181 reference
182 .split_once("://")
183 .map(|(scheme, _)| scheme.to_string())
184}
185
186pub struct Index {
188 db: Database,
189}
190
191impl Index {
192 pub fn open(dir: &Path) -> Result<Self, CoreError> {
194 store::ensure_dir(dir)?;
195 let path = dir.join(INDEX_FILE);
196 let existed = path.exists();
197 let db = Database::create(&path).map_err(|e| CoreError::Index(e.to_string()))?;
198 if !existed {
199 store::restrict(&path, 0o600)?;
200 }
201 Ok(Self { db })
202 }
203
204 pub fn upsert(&self, entry: &IndexEntry, key: &[u8; KEY_LEN]) -> Result<(), CoreError> {
206 let plaintext =
207 serde_json::to_vec(entry).map_err(|e| CoreError::Serialization(e.to_string()))?;
208 let sealed = seal_bytes(&plaintext, key)?;
209 let blob =
210 serde_json::to_vec(&sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
211
212 let txn = self.db.begin_write().map_err(idx)?;
213 {
214 let mut table = txn.open_table(META).map_err(idx)?;
215 table
216 .insert(entry.id.as_str(), blob.as_slice())
217 .map_err(idx)?;
218 }
219 txn.commit().map_err(idx)?;
220 Ok(())
221 }
222
223 pub fn remove(&self, id: &str) -> Result<(), CoreError> {
225 let txn = self.db.begin_write().map_err(idx)?;
226 {
227 let mut table = txn.open_table(META).map_err(idx)?;
228 table.remove(id).map_err(idx)?;
229 }
230 txn.commit().map_err(idx)?;
231 Ok(())
232 }
233
234 pub fn list(&self, key: &[u8; KEY_LEN]) -> Result<Vec<IndexEntry>, CoreError> {
237 let txn = self.db.begin_read().map_err(idx)?;
238 let table = match txn.open_table(META) {
239 Ok(t) => t,
240 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
242 Err(e) => return Err(CoreError::Index(e.to_string())),
243 };
244 let mut out = Vec::new();
245 for row in table.iter().map_err(idx)? {
246 let (_id, blob) = row.map_err(idx)?;
247 let sealed: SealedRecord = serde_json::from_slice(blob.value())
248 .map_err(|e| CoreError::Serialization(e.to_string()))?;
249 let plaintext = open_bytes(&sealed, key)?;
250 let entry: IndexEntry = serde_json::from_slice(&plaintext)
251 .map_err(|e| CoreError::Serialization(e.to_string()))?;
252 out.push(entry);
253 }
254 out.sort_by(|a, b| a.id.cmp(&b.id));
255 Ok(out)
256 }
257
258 pub fn generation(&self) -> Result<u64, CoreError> {
260 let txn = self.db.begin_read().map_err(idx)?;
261 let table = match txn.open_table(GEN) {
262 Ok(t) => t,
263 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
264 Err(e) => return Err(CoreError::Index(e.to_string())),
265 };
266 Ok(table
267 .get(GEN_KEY)
268 .map_err(idx)?
269 .map(|v| v.value())
270 .unwrap_or(0))
271 }
272
273 pub fn rebuild_from(
278 &self,
279 store_dir: &Path,
280 origin: &str,
281 key: &[u8; KEY_LEN],
282 ) -> Result<store::LoadOutcome, CoreError> {
283 let outcome = store::load_all(store_dir, key)?;
284 let next_gen = self.generation()?.saturating_add(1);
285
286 let txn = self.db.begin_write().map_err(idx)?;
287 txn.delete_table(META).map_err(idx)?;
289 {
290 let mut table = txn.open_table(META).map_err(idx)?;
291 for (id, record) in &outcome.records {
292 let path = store::record_path_for_id(store_dir, id);
293 let entry = IndexEntry::from_record(id, record, origin, &path.to_string_lossy());
294 let plaintext = serde_json::to_vec(&entry)
295 .map_err(|e| CoreError::Serialization(e.to_string()))?;
296 let sealed = seal_bytes(&plaintext, key)?;
297 let blob = serde_json::to_vec(&sealed)
298 .map_err(|e| CoreError::Serialization(e.to_string()))?;
299 table.insert(id.as_str(), blob.as_slice()).map_err(idx)?;
300 }
301 let mut gen_table = txn.open_table(GEN).map_err(idx)?;
302 gen_table.insert(GEN_KEY, next_gen).map_err(idx)?;
303 }
304 txn.commit().map_err(idx)?;
305 Ok(outcome)
306 }
307}
308
309fn idx<E: std::fmt::Display>(e: E) -> CoreError {
311 CoreError::Index(e.to_string())
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::coordinate::Coordinate;
318 use crate::crypto::seal;
319 use crate::secret::SecretValue;
320
321 fn key() -> [u8; KEY_LEN] {
322 [0x33; KEY_LEN]
323 }
324
325 fn literal(value: &str, k: &str) -> SecretRecord {
326 SecretRecord::Literal {
327 value: SecretValue::from(value),
328 sensitivity: Sensitivity::Medium,
329 revealable: false,
330 environment: "prod".to_string(),
331 component: "db".to_string(),
332 key: k.to_string(),
333 description: None,
334 created: "2026-05-30T00:00:00Z".to_string(),
335 updated: "2026-05-30T00:00:00Z".to_string(),
336 }
337 }
338
339 #[test]
340 fn upsert_then_list_round_trips() {
341 let dir = tempfile::tempdir().unwrap();
342 let index = Index::open(dir.path()).unwrap();
343 let entry = IndexEntry::from_record(
344 "abc",
345 &literal("hunter2", "password"),
346 "global",
347 "/x/abc.sec",
348 );
349 index.upsert(&entry, &key()).unwrap();
350
351 let listed = index.list(&key()).unwrap();
352 assert_eq!(listed.len(), 1);
353 assert_eq!(listed[0], entry);
354 assert_eq!(listed[0].mode, RecordMode::Literal);
355 assert!(listed[0].fingerprint.is_some());
356 }
357
358 #[test]
359 fn remove_drops_entry() {
360 let dir = tempfile::tempdir().unwrap();
361 let index = Index::open(dir.path()).unwrap();
362 let entry = IndexEntry::from_record("abc", &literal("v", "k"), "global", "/x/abc.sec");
363 index.upsert(&entry, &key()).unwrap();
364 index.remove("abc").unwrap();
365 assert!(index.list(&key()).unwrap().is_empty());
366 }
367
368 #[test]
369 fn reference_entry_has_scheme_and_no_fingerprint() {
370 let dir = tempfile::tempdir().unwrap();
371 let index = Index::open(dir.path()).unwrap();
372 let record = SecretRecord::Reference {
373 reference: "azure-kv://corp-kv/db-url".to_string(),
374 sensitivity: Sensitivity::High,
375 revealable: false,
376 environment: "prod".to_string(),
377 component: "db".to_string(),
378 key: "url".to_string(),
379 description: None,
380 created: "2026-05-30T00:00:00Z".to_string(),
381 updated: "2026-05-30T00:00:00Z".to_string(),
382 };
383 let entry = IndexEntry::from_record("ref1", &record, "global", "/x/ref1.sec");
384 index.upsert(&entry, &key()).unwrap();
385 let listed = index.list(&key()).unwrap();
386 assert_eq!(listed[0].mode, RecordMode::Reference);
387 assert_eq!(listed[0].ref_scheme.as_deref(), Some("azure-kv"));
388 assert!(listed[0].fingerprint.is_none());
389 }
390
391 #[test]
392 fn rebuild_reconstructs_and_bumps_generation() {
393 let dir = tempfile::tempdir().unwrap();
394 let a: Coordinate = "secret:prod/db/a".parse().unwrap();
396 let b: Coordinate = "secret:prod/db/b".parse().unwrap();
397 store::write_record(dir.path(), &a, &seal(&literal("va", "a"), &key()).unwrap()).unwrap();
398 store::write_record(dir.path(), &b, &seal(&literal("vb", "b"), &key()).unwrap()).unwrap();
399
400 let index = Index::open(dir.path()).unwrap();
401 assert_eq!(index.generation().unwrap(), 0);
402
403 let outcome = index.rebuild_from(dir.path(), "global", &key()).unwrap();
404 assert_eq!(outcome.records.len(), 2);
405 assert_eq!(index.list(&key()).unwrap().len(), 2);
406 assert_eq!(index.generation().unwrap(), 1);
407
408 index.rebuild_from(dir.path(), "global", &key()).unwrap();
410 assert_eq!(index.list(&key()).unwrap().len(), 2);
411 assert_eq!(index.generation().unwrap(), 2);
412 }
413
414 #[test]
415 fn raw_index_bytes_hold_no_plaintext_or_full_fingerprint() {
416 let dir = tempfile::tempdir().unwrap();
417 let index = Index::open(dir.path()).unwrap();
418 let value = "super-secret-value";
419 let entry =
420 IndexEntry::from_record("abc", &literal(value, "password"), "global", "/x/abc.sec");
421 index.upsert(&entry, &key()).unwrap();
422 drop(index); let raw = std::fs::read(dir.path().join(INDEX_FILE)).unwrap();
425 assert!(!contains(&raw, value.as_bytes()));
427 assert!(!contains(&raw, b"prod/db/password"));
429 let full = blake3::hash(value.as_bytes()).to_hex().to_string();
431 assert!(!contains(&raw, full.as_bytes()));
432 }
433
434 fn contains(haystack: &[u8], needle: &[u8]) -> bool {
435 haystack.windows(needle.len()).any(|w| w == needle)
436 }
437}