1#![forbid(unsafe_code)]
12
13use redb::{Database, ReadableTable, TableDefinition};
14use serde::{Deserialize, Serialize};
15use std::fmt::Display;
16use std::path::Path;
17use vanta_core::{Area, VtaError, VtaResult};
18
19pub const SCHEMA_VERSION: u32 = 1;
21
22const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
23const STORE_INDEX: TableDefinition<&str, &[u8]> = TableDefinition::new("store_index");
24const GENERATIONS: TableDefinition<u64, &[u8]> = TableDefinition::new("generations");
25const RESOLUTION_CACHE: TableDefinition<&str, &[u8]> = TableDefinition::new("resolution_cache");
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct StoreEntryMeta {
30 pub tool: String,
31 pub version: String,
32 pub platform: String,
33 pub size: u64,
34 pub sha256: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct GenerationRecord {
40 pub id: u64,
41 pub parent: Option<u64>,
42 pub command: String,
43 pub reason: String,
44 pub tools: Vec<(String, String)>,
46}
47
48pub struct State {
50 db: Database,
51}
52
53impl State {
54 pub fn open(path: &Path) -> VtaResult<State> {
57 let db = Database::create(path).map_err(db_err)?;
58 let state = State { db };
59 state.init()?;
60 Ok(state)
61 }
62
63 fn init(&self) -> VtaResult<()> {
64 let txn = self.db.begin_write().map_err(db_err)?;
65 {
66 let mut meta = txn.open_table(META).map_err(db_err)?;
67 if meta.get("schema_version").map_err(db_err)?.is_none() {
68 meta.insert("schema_version", SCHEMA_VERSION.to_string().as_bytes())
69 .map_err(db_err)?;
70 }
71 }
72 txn.commit().map_err(db_err)?;
73 Ok(())
74 }
75
76 pub fn schema_version(&self) -> VtaResult<u32> {
78 self.meta_get("schema_version")
79 .map(|opt| opt.and_then(|s| s.parse().ok()).unwrap_or(0))
80 }
81
82 pub fn put_store_entry(&self, store_key: &str, meta: &StoreEntryMeta) -> VtaResult<()> {
84 let bytes = serde_json::to_vec(meta).map_err(enc_err)?;
85 let txn = self.db.begin_write().map_err(db_err)?;
86 {
87 let mut t = txn.open_table(STORE_INDEX).map_err(db_err)?;
88 t.insert(store_key, bytes.as_slice()).map_err(db_err)?;
89 }
90 txn.commit().map_err(db_err)?;
91 Ok(())
92 }
93
94 pub fn get_store_entry(&self, store_key: &str) -> VtaResult<Option<StoreEntryMeta>> {
96 let txn = self.db.begin_read().map_err(db_err)?;
97 let t = txn.open_table(STORE_INDEX).map_err(db_err)?;
98 match t.get(store_key).map_err(db_err)? {
99 Some(guard) => Ok(Some(
100 serde_json::from_slice(guard.value()).map_err(enc_err)?,
101 )),
102 None => Ok(None),
103 }
104 }
105
106 pub fn append_generation(&self, rec: &GenerationRecord) -> VtaResult<()> {
108 let bytes = serde_json::to_vec(rec).map_err(enc_err)?;
109 let txn = self.db.begin_write().map_err(db_err)?;
110 {
111 let mut t = txn.open_table(GENERATIONS).map_err(db_err)?;
112 t.insert(rec.id, bytes.as_slice()).map_err(db_err)?;
113 }
114 txn.commit().map_err(db_err)?;
115 Ok(())
116 }
117
118 pub fn get_generation(&self, id: u64) -> VtaResult<Option<GenerationRecord>> {
120 let txn = self.db.begin_read().map_err(db_err)?;
121 let t = txn.open_table(GENERATIONS).map_err(db_err)?;
122 match t.get(id).map_err(db_err)? {
123 Some(guard) => Ok(Some(
124 serde_json::from_slice(guard.value()).map_err(enc_err)?,
125 )),
126 None => Ok(None),
127 }
128 }
129
130 pub fn set_current(&self, id: u64) -> VtaResult<()> {
132 let txn = self.db.begin_write().map_err(db_err)?;
133 {
134 let mut meta = txn.open_table(META).map_err(db_err)?;
135 meta.insert("current", id.to_string().as_bytes())
136 .map_err(db_err)?;
137 }
138 txn.commit().map_err(db_err)?;
139 Ok(())
140 }
141
142 pub fn current(&self) -> VtaResult<Option<u64>> {
144 self.meta_get("current")
145 .map(|opt| opt.and_then(|s| s.parse().ok()))
146 }
147
148 pub fn put_resolution(&self, config_hash: &str, bytes: &[u8]) -> VtaResult<()> {
150 let txn = self.db.begin_write().map_err(db_err)?;
151 {
152 let mut t = txn.open_table(RESOLUTION_CACHE).map_err(db_err)?;
153 t.insert(config_hash, bytes).map_err(db_err)?;
154 }
155 txn.commit().map_err(db_err)?;
156 Ok(())
157 }
158
159 pub fn get_resolution(&self, config_hash: &str) -> VtaResult<Option<Vec<u8>>> {
161 let txn = self.db.begin_read().map_err(db_err)?;
162 let t = txn.open_table(RESOLUTION_CACHE).map_err(db_err)?;
163 Ok(t.get(config_hash)
164 .map_err(db_err)?
165 .map(|g| g.value().to_vec()))
166 }
167
168 fn meta_get(&self, key: &str) -> VtaResult<Option<String>> {
169 let txn = self.db.begin_read().map_err(db_err)?;
170 let t = txn.open_table(META).map_err(db_err)?;
171 Ok(t.get(key)
172 .map_err(db_err)?
173 .map(|g| String::from_utf8_lossy(g.value()).into_owned()))
174 }
175}
176
177fn db_err<E: Display>(e: E) -> VtaError {
178 VtaError::new(Area::Store, 10, format!("state db: {e}"))
179}
180
181fn enc_err<E: Display>(e: E) -> VtaError {
182 VtaError::new(Area::Store, 11, format!("state encode/decode: {e}"))
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn temp_db(tag: &str) -> std::path::PathBuf {
190 let p =
191 std::env::temp_dir().join(format!("vanta-state-{}-{}.redb", tag, std::process::id()));
192 let _ = std::fs::remove_file(&p);
193 p
194 }
195
196 #[test]
197 fn schema_initialized() {
198 let path = temp_db("schema");
199 let s = State::open(&path).unwrap();
200 assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION);
201 let _ = std::fs::remove_file(&path);
202 }
203
204 #[test]
205 fn store_entry_roundtrip() {
206 let path = temp_db("store");
207 let s = State::open(&path).unwrap();
208 let meta = StoreEntryMeta {
209 tool: "node".into(),
210 version: "24.6.0".into(),
211 platform: "macos/aarch64".into(),
212 size: 24117248,
213 sha256: "5f2c".into(),
214 };
215 s.put_store_entry("blake3-aa3f", &meta).unwrap();
216 assert_eq!(s.get_store_entry("blake3-aa3f").unwrap(), Some(meta));
217 assert_eq!(s.get_store_entry("blake3-missing").unwrap(), None);
218 let _ = std::fs::remove_file(&path);
219 }
220
221 #[test]
222 fn generations_and_current() {
223 let path = temp_db("gen");
224 let s = State::open(&path).unwrap();
225 let rec = GenerationRecord {
226 id: 1,
227 parent: None,
228 command: "vanta add node@24".into(),
229 reason: "add".into(),
230 tools: vec![("node".into(), "blake3-aa3f".into())],
231 };
232 s.append_generation(&rec).unwrap();
233 s.set_current(1).unwrap();
234 assert_eq!(s.current().unwrap(), Some(1));
235 assert_eq!(s.get_generation(1).unwrap(), Some(rec));
236 assert_eq!(s.get_generation(2).unwrap(), None);
237 let _ = std::fs::remove_file(&path);
238 }
239}