use std::path::Path;
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
use crate::crypto::{KEY_LEN, SealedRecord, open_bytes, seal_bytes};
use crate::error::CoreError;
use crate::fingerprint::fingerprint;
use crate::record::SecretRecord;
use crate::sensitivity::Sensitivity;
use crate::store;
pub const INDEX_FILE: &str = "index.redb";
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
const GEN: TableDefinition<&str, u64> = TableDefinition::new("generation");
const GEN_KEY: &str = "g";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RecordMode {
Literal,
Reference,
Keypair,
Totp,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IndexEntry {
pub id: String,
pub environment: String,
pub component: String,
pub key: String,
pub sensitivity: Sensitivity,
pub mode: RecordMode,
pub ref_scheme: Option<String>,
pub fingerprint: Option<String>,
pub created: String,
pub updated: String,
pub origin: String,
pub record_path: String,
}
impl IndexEntry {
pub fn from_record(id: &str, record: &SecretRecord, origin: &str, record_path: &str) -> Self {
let (sensitivity, environment, component, key, created, updated) = match record {
SecretRecord::Literal {
sensitivity,
environment,
component,
key,
created,
updated,
..
}
| SecretRecord::Reference {
sensitivity,
environment,
component,
key,
created,
updated,
..
}
| SecretRecord::Keypair {
sensitivity,
environment,
component,
key,
created,
updated,
..
}
| SecretRecord::Totp {
sensitivity,
environment,
component,
key,
created,
updated,
..
} => (sensitivity, environment, component, key, created, updated),
};
let (mode, ref_scheme, fingerprint) = match record {
SecretRecord::Literal { value, .. } => {
(RecordMode::Literal, None, Some(fingerprint(value.expose())))
}
SecretRecord::Reference { reference, .. } => {
(RecordMode::Reference, ref_scheme(reference), None)
}
SecretRecord::Keypair { public, .. } => (
RecordMode::Keypair,
None,
Some(fingerprint(public.as_bytes())),
),
SecretRecord::Totp {
algorithm,
digits,
period,
..
} => (
RecordMode::Totp,
None,
Some(fingerprint(
format!("totp:{}:{digits}:{period}", algorithm.as_str()).as_bytes(),
)),
),
};
IndexEntry {
id: id.to_string(),
environment: environment.clone(),
component: component.clone(),
key: key.clone(),
sensitivity: *sensitivity,
mode,
ref_scheme,
fingerprint,
created: created.clone(),
updated: updated.clone(),
origin: origin.to_string(),
record_path: record_path.to_string(),
}
}
pub fn coordinate(&self) -> String {
format!("{}/{}/{}", self.environment, self.component, self.key)
}
}
fn ref_scheme(reference: &str) -> Option<String> {
reference
.split_once("://")
.map(|(scheme, _)| scheme.to_string())
}
pub struct Index {
db: Database,
}
impl Index {
pub fn open(dir: &Path) -> Result<Self, CoreError> {
store::ensure_dir(dir)?;
let path = dir.join(INDEX_FILE);
let existed = path.exists();
let db = Database::create(&path).map_err(|e| CoreError::Index(e.to_string()))?;
if !existed {
store::restrict(&path, 0o600)?;
}
Ok(Self { db })
}
pub fn upsert(&self, entry: &IndexEntry, key: &[u8; KEY_LEN]) -> Result<(), CoreError> {
let plaintext =
serde_json::to_vec(entry).map_err(|e| CoreError::Serialization(e.to_string()))?;
let sealed = seal_bytes(&plaintext, key)?;
let blob =
serde_json::to_vec(&sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
let txn = self.db.begin_write().map_err(idx)?;
{
let mut table = txn.open_table(META).map_err(idx)?;
table
.insert(entry.id.as_str(), blob.as_slice())
.map_err(idx)?;
}
txn.commit().map_err(idx)?;
Ok(())
}
pub fn remove(&self, id: &str) -> Result<(), CoreError> {
let txn = self.db.begin_write().map_err(idx)?;
{
let mut table = txn.open_table(META).map_err(idx)?;
table.remove(id).map_err(idx)?;
}
txn.commit().map_err(idx)?;
Ok(())
}
pub fn list(&self, key: &[u8; KEY_LEN]) -> Result<Vec<IndexEntry>, CoreError> {
let txn = self.db.begin_read().map_err(idx)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
Err(e) => return Err(CoreError::Index(e.to_string())),
};
let mut out = Vec::new();
for row in table.iter().map_err(idx)? {
let (_id, blob) = row.map_err(idx)?;
let sealed: SealedRecord = serde_json::from_slice(blob.value())
.map_err(|e| CoreError::Serialization(e.to_string()))?;
let plaintext = open_bytes(&sealed, key)?;
let entry: IndexEntry = serde_json::from_slice(&plaintext)
.map_err(|e| CoreError::Serialization(e.to_string()))?;
out.push(entry);
}
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
pub fn generation(&self) -> Result<u64, CoreError> {
let txn = self.db.begin_read().map_err(idx)?;
let table = match txn.open_table(GEN) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
Err(e) => return Err(CoreError::Index(e.to_string())),
};
Ok(table
.get(GEN_KEY)
.map_err(idx)?
.map(|v| v.value())
.unwrap_or(0))
}
pub fn rebuild_from(
&self,
store_dir: &Path,
origin: &str,
key: &[u8; KEY_LEN],
) -> Result<store::LoadOutcome, CoreError> {
let outcome = store::load_all(store_dir, key)?;
let next_gen = self.generation()?.saturating_add(1);
let txn = self.db.begin_write().map_err(idx)?;
txn.delete_table(META).map_err(idx)?;
{
let mut table = txn.open_table(META).map_err(idx)?;
for (id, record) in &outcome.records {
let path = store::record_path_for_id(store_dir, id);
let entry = IndexEntry::from_record(id, record, origin, &path.to_string_lossy());
let plaintext = serde_json::to_vec(&entry)
.map_err(|e| CoreError::Serialization(e.to_string()))?;
let sealed = seal_bytes(&plaintext, key)?;
let blob = serde_json::to_vec(&sealed)
.map_err(|e| CoreError::Serialization(e.to_string()))?;
table.insert(id.as_str(), blob.as_slice()).map_err(idx)?;
}
let mut gen_table = txn.open_table(GEN).map_err(idx)?;
gen_table.insert(GEN_KEY, next_gen).map_err(idx)?;
}
txn.commit().map_err(idx)?;
Ok(outcome)
}
}
fn idx<E: std::fmt::Display>(e: E) -> CoreError {
CoreError::Index(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coordinate::Coordinate;
use crate::crypto::seal;
use crate::secret::SecretValue;
fn key() -> [u8; KEY_LEN] {
[0x33; KEY_LEN]
}
fn literal(value: &str, k: &str) -> SecretRecord {
SecretRecord::Literal {
value: SecretValue::from(value),
sensitivity: Sensitivity::Medium,
revealable: false,
environment: "prod".to_string(),
component: "db".to_string(),
key: k.to_string(),
description: None,
created: "2026-05-30T00:00:00Z".to_string(),
updated: "2026-05-30T00:00:00Z".to_string(),
}
}
#[test]
fn upsert_then_list_round_trips() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(dir.path()).unwrap();
let entry = IndexEntry::from_record(
"abc",
&literal("hunter2", "password"),
"global",
"/x/abc.sec",
);
index.upsert(&entry, &key()).unwrap();
let listed = index.list(&key()).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0], entry);
assert_eq!(listed[0].mode, RecordMode::Literal);
assert!(listed[0].fingerprint.is_some());
}
#[test]
fn remove_drops_entry() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(dir.path()).unwrap();
let entry = IndexEntry::from_record("abc", &literal("v", "k"), "global", "/x/abc.sec");
index.upsert(&entry, &key()).unwrap();
index.remove("abc").unwrap();
assert!(index.list(&key()).unwrap().is_empty());
}
#[test]
fn reference_entry_has_scheme_and_no_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(dir.path()).unwrap();
let record = SecretRecord::Reference {
reference: "azure-kv://corp-kv/db-url".to_string(),
sensitivity: Sensitivity::High,
revealable: false,
environment: "prod".to_string(),
component: "db".to_string(),
key: "url".to_string(),
description: None,
created: "2026-05-30T00:00:00Z".to_string(),
updated: "2026-05-30T00:00:00Z".to_string(),
};
let entry = IndexEntry::from_record("ref1", &record, "global", "/x/ref1.sec");
index.upsert(&entry, &key()).unwrap();
let listed = index.list(&key()).unwrap();
assert_eq!(listed[0].mode, RecordMode::Reference);
assert_eq!(listed[0].ref_scheme.as_deref(), Some("azure-kv"));
assert!(listed[0].fingerprint.is_none());
}
#[test]
fn rebuild_reconstructs_and_bumps_generation() {
let dir = tempfile::tempdir().unwrap();
let a: Coordinate = "secret:prod/db/a".parse().unwrap();
let b: Coordinate = "secret:prod/db/b".parse().unwrap();
store::write_record(dir.path(), &a, &seal(&literal("va", "a"), &key()).unwrap()).unwrap();
store::write_record(dir.path(), &b, &seal(&literal("vb", "b"), &key()).unwrap()).unwrap();
let index = Index::open(dir.path()).unwrap();
assert_eq!(index.generation().unwrap(), 0);
let outcome = index.rebuild_from(dir.path(), "global", &key()).unwrap();
assert_eq!(outcome.records.len(), 2);
assert_eq!(index.list(&key()).unwrap().len(), 2);
assert_eq!(index.generation().unwrap(), 1);
index.rebuild_from(dir.path(), "global", &key()).unwrap();
assert_eq!(index.list(&key()).unwrap().len(), 2);
assert_eq!(index.generation().unwrap(), 2);
}
#[test]
fn raw_index_bytes_hold_no_plaintext_or_full_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(dir.path()).unwrap();
let value = "super-secret-value";
let entry =
IndexEntry::from_record("abc", &literal(value, "password"), "global", "/x/abc.sec");
index.upsert(&entry, &key()).unwrap();
drop(index);
let raw = std::fs::read(dir.path().join(INDEX_FILE)).unwrap();
assert!(!contains(&raw, value.as_bytes()));
assert!(!contains(&raw, b"prod/db/password"));
let full = blake3::hash(value.as_bytes()).to_hex().to_string();
assert!(!contains(&raw, full.as_bytes()));
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
}