1use rusqlite::{params, Connection, OptionalExtension};
40use serde_json::{Map, Value};
41use sha2::{Digest, Sha256};
42use std::collections::{HashMap, HashSet};
43
44pub const KEEP_SAVES: i64 = 2000;
47
48pub const KEEP_DAYS: i64 = 30;
52
53pub const HARD_KEEP_SAVES: i64 = 200_000;
55
56fn retention_cutoff(now: &str) -> Option<String> {
58 let t =
59 time::OffsetDateTime::parse(now, &time::format_description::well_known::Rfc3339).ok()?;
60 let c = t.checked_sub(time::Duration::days(KEEP_DAYS))?;
61 Some(format!(
62 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
63 c.year(),
64 c.month() as u8,
65 c.day(),
66 c.hour(),
67 c.minute(),
68 c.second()
69 ))
70}
71
72fn prune_saves(conn: &Connection, seq: i64, now: &str, keep: i64, hard: i64) -> Result<(), String> {
77 let cutoff = retention_cutoff(now).unwrap_or_else(|| "0".into());
78 conn.execute(
79 "DELETE FROM vault_saves WHERE (seq <= ?1 AND at < ?2) OR seq <= ?3",
80 params![seq - keep, cutoff, seq - hard],
81 )
82 .map_err(|e| e.to_string())?;
83 conn.execute(
84 "DELETE FROM vault_changes WHERE seq < COALESCE((SELECT MIN(seq) FROM vault_saves), ?1)",
85 params![seq],
86 )
87 .map_err(|e| e.to_string())?;
88 Ok(())
89}
90
91pub fn merge_window(conn: &Connection) -> Result<Option<(String, i64)>, String> {
94 conn.query_row("SELECT MIN(at), COUNT(*) FROM vault_saves", [], |r| {
95 Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
96 })
97 .map(|(at, n)| at.map(|a| (a, n)))
98 .map_err(|e| e.to_string())
99}
100
101const KIND_ENTRY: &str = "entry";
102const KIND_PROJECT: &str = "project";
103const KIND_CHUNK: &str = "chunk";
104const KIND_CATEGORIES: &str = "categories";
105const KIND_DOC: &str = "doc";
106
107const CHUNK_SEP: char = '\u{3}';
110
111const SPLIT_KEYS: [&str; 3] = ["api_keys", "projects", "user_categories"];
112
113pub fn init_schema(conn: &Connection) -> Result<(), String> {
114 conn.execute_batch(
115 "CREATE TABLE IF NOT EXISTS vault_rows (
116 kind TEXT NOT NULL,
117 key TEXT NOT NULL,
118 pos INTEGER NOT NULL,
119 data TEXT NOT NULL,
120 rev TEXT NOT NULL,
121 PRIMARY KEY (kind, key)
122 );
123 CREATE INDEX IF NOT EXISTS vault_rows_pos ON vault_rows (kind, pos);
124 CREATE TABLE IF NOT EXISTS vault_history (
125 key TEXT PRIMARY KEY,
126 data TEXT NOT NULL,
127 rev TEXT NOT NULL
128 );
129 CREATE TABLE IF NOT EXISTS vault_saves (
130 seq INTEGER PRIMARY KEY AUTOINCREMENT,
131 state_hash TEXT NOT NULL,
132 at TEXT NOT NULL
133 );
134 CREATE TABLE IF NOT EXISTS vault_changes (
135 seq INTEGER NOT NULL,
136 kind TEXT NOT NULL,
137 key TEXT NOT NULL,
138 prev_rev TEXT,
139 new_rev TEXT
140 );
141 CREATE INDEX IF NOT EXISTS vault_changes_seq ON vault_changes (seq);",
142 )
143 .map_err(|e| e.to_string())
144}
145
146fn sha(s: &str) -> String {
147 format!("{:x}", Sha256::digest(s.as_bytes()))
148}
149
150struct HashWriter(Sha256);
153
154impl std::io::Write for HashWriter {
155 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
156 self.0.update(buf);
157 Ok(buf.len())
158 }
159 fn flush(&mut self) -> std::io::Result<()> {
160 Ok(())
161 }
162}
163
164fn rev_of(v: &Value) -> String {
165 let mut w = HashWriter(Sha256::new());
166 let _ = serde_json::to_writer(&mut w, v);
167 format!("{:x}", w.0.finalize())
168}
169
170#[derive(Clone)]
172pub struct Ent {
173 pub kind: &'static str,
174 pub key: String,
176 pub data: Value,
178 pub rev: String,
179 pub history: Option<Value>,
181}
182
183impl Ent {
184 fn new(kind: &'static str, key: String, data: Value, history: Option<Value>) -> Self {
185 let rev = rev_of(&data);
186 Self {
187 kind,
188 key,
189 data,
190 rev,
191 history,
192 }
193 }
194
195 pub fn full(&self) -> Value {
197 let mut v = self.data.clone();
198 if let (Some(h), Some(o)) = (&self.history, v.as_object_mut()) {
199 o.insert("version_history".into(), h.clone());
200 }
201 v
202 }
203
204 pub fn provider(&self) -> String {
205 self.data
206 .get("provider")
207 .and_then(Value::as_str)
208 .unwrap_or("")
209 .to_string()
210 }
211}
212
213fn unique_keys(cks: Vec<String>) -> Vec<String> {
217 let mut seen: HashMap<String, usize> = HashMap::new();
218 cks.into_iter()
219 .map(|ck| {
220 let n = seen.entry(ck.clone()).or_insert(0);
221 *n += 1;
222 if *n == 1 {
223 ck
224 } else {
225 format!("{ck}\u{2}{n}")
226 }
227 })
228 .collect()
229}
230
231fn project_ck(p: &Value) -> String {
232 let id = p.get("id").and_then(Value::as_str).unwrap_or("");
233 if !id.is_empty() {
234 return format!("id\u{1}{id}");
235 }
236 format!(
237 "name\u{1}{}",
238 p.get("name").and_then(Value::as_str).unwrap_or("")
239 )
240}
241
242pub fn split(mut doc: Value) -> Vec<Ent> {
245 let mut out = Vec::new();
246 let mut take = |k: &str| -> Vec<Value> {
247 match doc.get_mut(k).map(Value::take) {
248 Some(Value::Array(a)) => a,
249 _ => Vec::new(),
250 }
251 };
252
253 let entries = take("api_keys");
254 let keys = unique_keys(entries.iter().map(crate::entry_ck).collect());
255 for (e, key) in entries.into_iter().zip(keys) {
256 let mut data = e;
257 let history = data
258 .as_object_mut()
259 .and_then(|o| o.remove("version_history"));
260 out.push(Ent::new(KIND_ENTRY, key, data, history));
261 }
262 let projects = take("projects");
263 let keys = unique_keys(projects.iter().map(project_ck).collect());
264 let mut chunk_rows = Vec::new();
265 for (mut p, key) in projects.into_iter().zip(keys) {
266 if let Some(Value::Array(chunks)) = p.get_mut("chunks").map(Value::take) {
270 p["chunks"] = Value::Array(Vec::new());
271 let cks = unique_keys(
272 chunks
273 .iter()
274 .enumerate()
275 .map(|(i, c)| match c.get("id").and_then(Value::as_str) {
276 Some(id) if !id.is_empty() => format!("id\u{1}{id}"),
277 _ => format!("pos\u{1}{i}"),
278 })
279 .collect(),
280 );
281 for (c, ck) in chunks.into_iter().zip(cks) {
282 chunk_rows.push(Ent::new(
283 KIND_CHUNK,
284 format!("{key}{CHUNK_SEP}{ck}"),
285 c,
286 None,
287 ));
288 }
289 }
290 out.push(Ent::new(KIND_PROJECT, key, p, None));
291 }
292 out.extend(chunk_rows);
293 let cats = take("user_categories");
294 out.push(Ent::new(
295 KIND_CATEGORIES,
296 String::new(),
297 Value::Array(cats),
298 None,
299 ));
300 let mut extra = Map::new();
301 if let Value::Object(o) = doc {
302 for (k, v) in o {
303 if !SPLIT_KEYS.contains(&k.as_str()) {
304 extra.insert(k, v);
305 }
306 }
307 }
308 out.push(Ent::new(
309 KIND_DOC,
310 String::new(),
311 Value::Object(extra),
312 None,
313 ));
314 out
315}
316
317pub fn join(ents: Vec<Ent>) -> Value {
319 let mut doc = Map::new();
320 let mut entries = Vec::new();
321 let mut projects: Vec<(String, Value)> = Vec::new();
322 let mut chunks: HashMap<String, Vec<Value>> = HashMap::new();
323 let mut cats = Value::Array(vec![]);
324 for e in ents {
325 match e.kind {
326 KIND_ENTRY => {
327 let mut v = e.data;
328 if let (Some(h), Some(o)) = (e.history, v.as_object_mut()) {
329 o.insert("version_history".into(), h);
330 }
331 entries.push(v);
332 }
333 KIND_PROJECT => projects.push((e.key, e.data)),
334 KIND_CHUNK => {
335 if let Some((pk, _)) = e.key.split_once(CHUNK_SEP) {
336 chunks.entry(pk.to_string()).or_default().push(e.data);
337 }
338 }
339 KIND_CATEGORIES => cats = e.data,
340 KIND_DOC => {
341 if let Value::Object(o) = e.data {
342 for (k, v) in o {
343 doc.insert(k, v);
344 }
345 }
346 }
347 _ => {}
348 }
349 }
350 doc.insert("api_keys".into(), Value::Array(entries));
351 let projects = projects
352 .into_iter()
353 .map(|(key, mut p)| {
354 if let Some(c) = chunks.remove(&key) {
356 p["chunks"] = Value::Array(c);
357 }
358 p
359 })
360 .collect();
361 doc.insert("projects".into(), Value::Array(projects));
362 doc.insert("user_categories".into(), cats);
363 Value::Object(doc)
364}
365
366fn par_parse(texts: Vec<String>) -> Vec<Result<Value, serde_json::Error>> {
372 const MIN_PER_THREAD: usize = 512;
373 let threads = std::thread::available_parallelism()
374 .map(|n| n.get())
375 .unwrap_or(1)
376 .min(texts.len() / MIN_PER_THREAD)
377 .max(1);
378 if threads == 1 {
379 return texts.iter().map(|t| serde_json::from_str(t)).collect();
380 }
381 let per = texts.len().div_ceil(threads);
382 std::thread::scope(|sc| {
383 let handles: Vec<_> = texts
384 .chunks(per)
385 .map(|c| {
386 sc.spawn(move || {
387 c.iter()
388 .map(|t| serde_json::from_str(t))
389 .collect::<Vec<_>>()
390 })
391 })
392 .collect();
393 handles
394 .into_iter()
395 .flat_map(|h| h.join().unwrap_or_default())
396 .collect()
397 })
398}
399
400fn history_map(conn: &Connection) -> Result<HashMap<String, Value>, String> {
401 let mut stmt = conn
402 .prepare("SELECT key, data FROM vault_history")
403 .map_err(|e| e.to_string())?;
404 let rows = stmt
405 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
406 .map_err(|e| e.to_string())?;
407 let (keys, texts): (Vec<String>, Vec<String>) = rows
408 .collect::<Result<Vec<_>, _>>()
409 .map_err(|e| e.to_string())?
410 .into_iter()
411 .unzip();
412 Ok(keys
413 .into_iter()
414 .zip(par_parse(texts))
415 .filter_map(|(k, v)| v.ok().map(|v| (k, v)))
416 .collect())
417}
418
419fn ent_from_row(
420 kind: &'static str,
421 key: String,
422 data: &str,
423 rev: String,
424 history: Option<Value>,
425) -> Result<Ent, String> {
426 Ok(Ent {
427 kind,
428 key,
429 data: serde_json::from_str(data).map_err(|e| e.to_string())?,
430 rev,
431 history,
432 })
433}
434
435fn kind_static(k: &str) -> &'static str {
436 match k {
437 KIND_ENTRY => KIND_ENTRY,
438 KIND_PROJECT => KIND_PROJECT,
439 KIND_CHUNK => KIND_CHUNK,
440 KIND_CATEGORIES => KIND_CATEGORIES,
441 _ => KIND_DOC,
442 }
443}
444
445pub fn load_ents(conn: &Connection) -> Result<Option<Vec<Ent>>, String> {
447 load_ents_opts(conn, true)
448}
449
450pub fn load_ents_opts(conn: &Connection, with_history: bool) -> Result<Option<Vec<Ent>>, String> {
454 let mut stmt = conn
455 .prepare("SELECT kind, key, data, rev FROM vault_rows ORDER BY kind, pos")
456 .map_err(|e| e.to_string())?;
457 let rows = stmt
458 .query_map([], |r| {
459 Ok((
460 r.get::<_, String>(0)?,
461 r.get::<_, String>(1)?,
462 r.get::<_, String>(2)?,
463 r.get::<_, String>(3)?,
464 ))
465 })
466 .map_err(|e| e.to_string())?;
467 let mut hist = if with_history {
468 history_map(conn)?
469 } else {
470 HashMap::new()
471 };
472 let mut stored = Vec::new();
473 let mut texts = Vec::new();
474 for r in rows {
475 let (kind, key, data, rev) = r.map_err(|e| e.to_string())?;
476 stored.push((kind, key, rev));
477 texts.push(data);
478 }
479 let mut by_kind: HashMap<&'static str, Vec<Ent>> = HashMap::new();
480 for ((kind, key, rev), parsed) in stored.into_iter().zip(par_parse(texts)) {
481 let k = kind_static(&kind);
482 let h = if k == KIND_ENTRY {
483 hist.remove(&key)
484 } else {
485 None
486 };
487 by_kind.entry(k).or_default().push(Ent {
488 kind: k,
489 key,
490 data: parsed.map_err(|e| e.to_string())?,
491 rev,
492 history: h,
493 });
494 }
495 if !by_kind.contains_key(KIND_DOC) {
496 return Ok(None); }
498 let mut out = Vec::new();
499 for k in [
500 KIND_ENTRY,
501 KIND_PROJECT,
502 KIND_CHUNK,
503 KIND_CATEGORIES,
504 KIND_DOC,
505 ] {
506 out.extend(by_kind.remove(k).unwrap_or_default());
507 }
508 Ok(Some(out))
509}
510
511pub fn load(conn: &Connection) -> Result<Option<Value>, String> {
512 Ok(load_ents(conn)?.map(join))
513}
514
515pub fn load_lite(conn: &Connection) -> Result<Option<Value>, String> {
517 Ok(load_ents_opts(conn, false)?.map(join))
518}
519
520pub type Snapshot = HashMap<(String, String), (String, i64)>;
522
523pub fn snapshot(conn: &Connection) -> Result<Snapshot, String> {
524 let mut stmt = conn
525 .prepare("SELECT kind, key, pos, rev FROM vault_rows")
526 .map_err(|e| e.to_string())?;
527 let rows = stmt
528 .query_map([], |r| {
529 Ok((
530 r.get::<_, String>(0)?,
531 r.get::<_, String>(1)?,
532 r.get::<_, i64>(2)?,
533 r.get::<_, String>(3)?,
534 ))
535 })
536 .map_err(|e| e.to_string())?;
537 let mut m = HashMap::new();
538 for r in rows {
539 let (k, key, pos, rev) = r.map_err(|e| e.to_string())?;
540 m.insert((k, key), (rev, pos));
541 }
542 Ok(m)
543}
544
545pub fn load_ent(conn: &Connection, kind: &'static str, key: &str) -> Result<Option<Ent>, String> {
547 let row: Option<(String, String)> = conn
548 .query_row(
549 "SELECT data, rev FROM vault_rows WHERE kind = ?1 AND key = ?2",
550 params![kind, key],
551 |r| Ok((r.get(0)?, r.get(1)?)),
552 )
553 .optional()
554 .map_err(|e| e.to_string())?;
555 let Some((data, rev)) = row else {
556 return Ok(None);
557 };
558 let history: Option<Value> = if kind == KIND_ENTRY {
559 conn.query_row(
560 "SELECT data FROM vault_history WHERE key = ?1",
561 params![key],
562 |r| r.get::<_, String>(0),
563 )
564 .optional()
565 .map_err(|e| e.to_string())?
566 .and_then(|s| serde_json::from_str(&s).ok())
567 } else {
568 None
569 };
570 Ok(Some(ent_from_row(
571 kind,
572 key.to_string(),
573 &data,
574 rev,
575 history,
576 )?))
577}
578
579pub fn entry_history(conn: &Connection, id: &str) -> Result<Option<Value>, String> {
582 let key = format!("id\u{1}{id}");
583 let exists: bool = conn
584 .query_row(
585 "SELECT 1 FROM vault_rows WHERE kind = 'entry' AND key = ?1",
586 params![key],
587 |_| Ok(true),
588 )
589 .optional()
590 .map_err(|e| e.to_string())?
591 .unwrap_or(false);
592 if !exists {
593 return Ok(None);
594 }
595 let text: Option<String> = conn
596 .query_row(
597 "SELECT data FROM vault_history WHERE key = ?1",
598 params![key],
599 |r| r.get(0),
600 )
601 .optional()
602 .map_err(|e| e.to_string())?;
603 Ok(Some(match text {
604 Some(t) => serde_json::from_str(&t).map_err(|e| e.to_string())?,
605 None => Value::Array(Vec::new()),
606 }))
607}
608
609type Acc = [u8; 32];
616
617fn row_hash(kind: &str, key: &str, pos: i64, rev: &str) -> Acc {
618 let mut h = Sha256::new();
619 h.update(kind.as_bytes());
620 h.update([1]);
621 h.update(key.as_bytes());
622 h.update([1]);
623 h.update(pos.to_string().as_bytes());
624 h.update([1]);
625 h.update(rev.as_bytes());
626 h.finalize().into()
627}
628
629fn xor(acc: &mut Acc, other: &Acc) {
630 for (a, b) in acc.iter_mut().zip(other) {
631 *a ^= b;
632 }
633}
634
635fn acc_from_rows(snap: &Snapshot) -> Acc {
636 let mut acc = [0u8; 32];
637 for ((k, key), (rev, pos)) in snap {
638 xor(&mut acc, &row_hash(k, key, *pos, rev));
639 }
640 acc
641}
642
643fn acc_hex(acc: &Acc) -> String {
644 acc.iter().map(|b| format!("{b:02x}")).collect()
645}
646
647fn acc_parse(s: &str) -> Option<Acc> {
648 if s.len() != 64 {
649 return None;
650 }
651 let mut out = [0u8; 32];
652 for (i, o) in out.iter_mut().enumerate() {
653 *o = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
654 }
655 Some(out)
656}
657
658fn stored_acc(conn: &Connection) -> Result<Option<Acc>, String> {
659 Ok(conn
660 .query_row(
661 "SELECT value FROM vault_meta WHERE key = 'state_acc'",
662 [],
663 |r| r.get::<_, String>(0),
664 )
665 .optional()
666 .map_err(|e| e.to_string())?
667 .and_then(|s| acc_parse(&s)))
668}
669
670fn token(seq: i64, hash: &str) -> String {
671 format!("{seq}.{hash}")
672}
673
674fn parse_token(t: &str) -> Option<(i64, &str)> {
675 let (s, h) = t.split_once('.')?;
676 Some((s.parse().ok()?, h))
677}
678
679pub fn version(conn: &Connection) -> Result<Option<String>, String> {
681 conn.query_row(
682 "SELECT value FROM vault_meta WHERE key = 'data_hash'",
683 [],
684 |r| r.get(0),
685 )
686 .optional()
687 .map_err(|e| e.to_string())
688}
689
690pub fn verify(conn: &Connection) -> Result<bool, String> {
692 let mut stmt = conn
693 .prepare("SELECT data, rev FROM vault_rows")
694 .map_err(|e| e.to_string())?;
695 let rows = stmt
696 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
697 .map_err(|e| e.to_string())?;
698 let mut any = false;
699 for r in rows {
700 let (data, rev) = r.map_err(|e| e.to_string())?;
701 any = true;
702 if sha(&data) != rev {
703 return Ok(false);
704 }
705 }
706 if !any {
707 return Ok(true);
708 }
709 let Some(stored) = version(conn)? else {
710 return Ok(true);
711 };
712 let fresh = acc_hex(&acc_from_rows(&snapshot(conn)?));
713 let recorded = stored_acc(conn)?.map(|a| acc_hex(&a));
714 Ok(parse_token(&stored)
715 .map(|(_, h)| h == fresh)
716 .unwrap_or(false)
717 && recorded.as_deref().map(|r| r == fresh).unwrap_or(true))
718}
719
720type Id = (String, String);
723
724pub fn merge(
729 conn: &Connection,
730 stored: &Snapshot,
731 incoming: Vec<Ent>,
732 expect: Option<&str>,
733) -> Result<(Vec<Ent>, bool), String> {
734 let cur_version = version(conn)?;
735 let expect = expect.map(|e| e.strip_suffix(crate::MERGED_SUFFIX).unwrap_or(e));
736 let mut touched: HashMap<Id, Option<String>> = HashMap::new();
738 match expect {
739 None => {}
740 Some(e) if cur_version.as_deref() == Some(e) => {}
741 Some(e) => {
742 let ok = parse_token(e).and_then(|(seq, hash)| {
743 let found: Option<String> = conn
744 .query_row(
745 "SELECT state_hash FROM vault_saves WHERE seq = ?1",
746 params![seq],
747 |r| r.get(0),
748 )
749 .optional()
750 .ok()
751 .flatten();
752 (found.as_deref() == Some(hash)).then_some(seq)
753 });
754 let Some(base) = ok else {
755 return Err(conflict(&[]));
756 };
757 let mut stmt = conn
758 .prepare("SELECT kind, key, prev_rev FROM vault_changes WHERE seq > ?1 ORDER BY seq ASC, rowid ASC")
759 .map_err(|e| e.to_string())?;
760 let rows = stmt
761 .query_map(params![base], |r| {
762 Ok((
763 r.get::<_, String>(0)?,
764 r.get::<_, String>(1)?,
765 r.get::<_, Option<String>>(2)?,
766 ))
767 })
768 .map_err(|e| e.to_string())?;
769 for r in rows {
770 let (k, key, prev) = r.map_err(|e| e.to_string())?;
771 touched.entry((k, key)).or_insert(prev);
772 }
773 }
774 }
775 if touched.is_empty() {
776 return Ok((incoming, false)); }
778
779 let mut conflicts: Vec<String> = Vec::new();
780 let mut out: Vec<Ent> = Vec::new();
781 let mut seen: HashSet<Id> = HashSet::new();
782 let mut adopted_theirs = false;
785
786 for ent in incoming {
787 let id: Id = (ent.kind.to_string(), ent.key.clone());
788 seen.insert(id.clone());
789 let Some(base_prev) = touched.get(&id) else {
790 out.push(ent);
791 continue;
792 };
793 let cur = stored.get(&id).map(|(rev, _)| rev);
794 if cur == Some(&ent.rev) {
795 out.push(ent); continue;
797 }
798 match base_prev {
799 Some(b) if *b == ent.rev => {
801 adopted_theirs = true;
802 if cur.is_some() {
803 if let Some(theirs) = load_ent(conn, ent.kind, &ent.key)? {
804 out.push(theirs);
805 }
806 } }
808 _ => conflicts.push(label(&ent)),
809 }
810 }
811 let mut leftovers: Vec<&Id> = stored.keys().filter(|id| !seen.contains(*id)).collect();
813 leftovers.sort();
814 for id in leftovers {
815 let kind = kind_static(&id.0);
816 match touched.get(id) {
817 None => {} Some(None) => {
819 adopted_theirs = true;
821 if let Some(theirs) = load_ent(conn, kind, &id.1)? {
822 out.push(theirs);
823 }
824 }
825 Some(Some(_)) => conflicts.push(
826 load_ent(conn, kind, &id.1)?
827 .map(|e| label(&e))
828 .unwrap_or_else(|| id.1.clone()),
829 ),
830 }
831 }
832 if !conflicts.is_empty() {
833 conflicts.sort();
834 conflicts.dedup();
835 return Err(conflict(&conflicts));
836 }
837 let order = |k: &str| match k {
839 KIND_ENTRY => 0,
840 KIND_PROJECT => 1,
841 KIND_CHUNK => 2,
842 KIND_CATEGORIES => 3,
843 _ => 4,
844 };
845 let live: HashSet<String> = out
848 .iter()
849 .filter(|e| e.kind == KIND_PROJECT)
850 .map(|e| e.key.clone())
851 .collect();
852 out.retain(|e| {
853 e.kind != KIND_CHUNK
854 || e.key
855 .split_once(CHUNK_SEP)
856 .is_some_and(|(pk, _)| live.contains(pk))
857 });
858 out.sort_by_key(|e| order(e.kind)); Ok((out, adopted_theirs))
860}
861
862fn label(e: &Ent) -> String {
863 match e.kind {
864 KIND_ENTRY => {
865 let p = e.provider();
866 if p.is_empty() {
867 e.key.replace('\u{1}', ":")
868 } else {
869 p
870 }
871 }
872 KIND_PROJECT => e
873 .data
874 .get("name")
875 .and_then(Value::as_str)
876 .unwrap_or(&e.key)
877 .to_string(),
878 KIND_CHUNK => {
879 let name = e.data.get("name").and_then(Value::as_str).unwrap_or("");
880 let project = e.key.split_once(CHUNK_SEP).map_or("", |(pk, _)| pk);
881 let project = project.split_once('\u{1}').map_or(project, |(_, v)| v);
882 format!(
883 "{project}: {}",
884 if name.is_empty() { "a chunk" } else { name }
885 )
886 }
887 KIND_CATEGORIES => "categories".into(),
888 _ => "vault settings".into(),
889 }
890}
891
892fn conflict(names: &[String]) -> String {
893 if names.is_empty() {
894 format!(
895 "{}: the vault changed since you last read it — reload and retry",
896 crate::CONFLICT_ERR
897 )
898 } else {
899 format!(
900 "{}: the vault changed since you last read it — you and another writer both changed: {}",
901 crate::CONFLICT_ERR,
902 names.join(", ")
903 )
904 }
905}
906
907pub fn write(
913 conn: &Connection,
914 stored: &Snapshot,
915 ents: &[Ent],
916 now: &str,
917) -> Result<String, String> {
918 let mut acc = match stored_acc(conn)? {
920 Some(a) => a,
921 None => acc_from_rows(stored),
922 };
923 let stored_hist: HashMap<String, String> = {
924 let mut stmt = conn
925 .prepare("SELECT key, rev FROM vault_history")
926 .map_err(|e| e.to_string())?;
927 let rows = stmt
928 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
929 .map_err(|e| e.to_string())?;
930 let mut m = HashMap::new();
931 for r in rows {
932 let (k, v) = r.map_err(|e| e.to_string())?;
933 m.insert(k, v);
934 }
935 m
936 };
937
938 let mut changes: Vec<(String, String, Option<String>, Option<String>)> = Vec::new();
939 let mut dirty = false;
940 let mut pos_by_kind: HashMap<&str, i64> = HashMap::new();
941 let mut keep: HashSet<Id> = HashSet::new();
942 let mut keep_hist: HashSet<String> = HashSet::new();
943
944 for e in ents {
945 let p = pos_by_kind.entry(e.kind).or_insert(0);
946 let pos = *p;
947 *p += 1;
948 let id: Id = (e.kind.to_string(), e.key.clone());
949 keep.insert(id.clone());
950 match stored.get(&id) {
951 None => {
952 conn.execute(
953 "INSERT INTO vault_rows (kind, key, pos, data, rev) VALUES (?1, ?2, ?3, ?4, ?5)",
954 params![e.kind, e.key, pos, e.data.to_string(), e.rev],
955 )
956 .map_err(|x| x.to_string())?;
957 xor(&mut acc, &row_hash(e.kind, &e.key, pos, &e.rev));
958 changes.push((e.kind.into(), e.key.clone(), None, Some(e.rev.clone())));
959 dirty = true;
960 }
961 Some((rev, old_pos)) if *rev != e.rev => {
962 xor(&mut acc, &row_hash(e.kind, &e.key, *old_pos, rev));
963 xor(&mut acc, &row_hash(e.kind, &e.key, pos, &e.rev));
964 conn.execute(
965 "UPDATE vault_rows SET pos = ?3, data = ?4, rev = ?5 WHERE kind = ?1 AND key = ?2",
966 params![e.kind, e.key, pos, e.data.to_string(), e.rev],
967 )
968 .map_err(|x| x.to_string())?;
969 changes.push((
970 e.kind.into(),
971 e.key.clone(),
972 Some(rev.clone()),
973 Some(e.rev.clone()),
974 ));
975 dirty = true;
976 }
977 Some((rev, old_pos)) => {
978 if *old_pos != pos {
979 xor(&mut acc, &row_hash(e.kind, &e.key, *old_pos, rev));
980 xor(&mut acc, &row_hash(e.kind, &e.key, pos, rev));
981 conn.execute(
982 "UPDATE vault_rows SET pos = ?3 WHERE kind = ?1 AND key = ?2",
983 params![e.kind, e.key, pos],
984 )
985 .map_err(|x| x.to_string())?;
986 dirty = true;
987 }
988 }
989 }
990 if e.kind == KIND_ENTRY {
991 if let Some(h) = e
992 .history
993 .as_ref()
994 .filter(|h| h.as_array().map(|a| !a.is_empty()).unwrap_or(false))
995 {
996 keep_hist.insert(e.key.clone());
997 let hrev = rev_of(h);
998 if stored_hist.get(&e.key) != Some(&hrev) {
999 conn.execute(
1000 "INSERT OR REPLACE INTO vault_history (key, data, rev) VALUES (?1, ?2, ?3)",
1001 params![e.key, h.to_string(), hrev],
1002 )
1003 .map_err(|x| x.to_string())?;
1004 dirty = true;
1005 }
1006 }
1007 }
1008 }
1009 for (id, (rev, old_pos)) in stored {
1010 if !keep.contains(id) {
1011 xor(&mut acc, &row_hash(&id.0, &id.1, *old_pos, rev));
1012 conn.execute(
1013 "DELETE FROM vault_rows WHERE kind = ?1 AND key = ?2",
1014 params![id.0, id.1],
1015 )
1016 .map_err(|x| x.to_string())?;
1017 changes.push((id.0.clone(), id.1.clone(), Some(rev.clone()), None));
1018 dirty = true;
1019 }
1020 }
1021 for k in stored_hist.keys() {
1022 if !keep_hist.contains(k) {
1023 conn.execute("DELETE FROM vault_history WHERE key = ?1", params![k])
1024 .map_err(|x| x.to_string())?;
1025 dirty = true;
1026 }
1027 }
1028
1029 if !dirty {
1030 if let Some(v) = version(conn)? {
1031 return Ok(v);
1032 }
1033 }
1034 let hash = acc_hex(&acc);
1035 conn.execute(
1036 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('state_acc', ?1)",
1037 params![hash],
1038 )
1039 .map_err(|e| e.to_string())?;
1040 conn.execute(
1041 "INSERT INTO vault_saves (state_hash, at) VALUES (?1, ?2)",
1042 params![hash, now],
1043 )
1044 .map_err(|e| e.to_string())?;
1045 let seq = conn.last_insert_rowid();
1046 for (k, key, prev, new) in changes {
1047 conn.execute(
1048 "INSERT INTO vault_changes (seq, kind, key, prev_rev, new_rev) VALUES (?1, ?2, ?3, ?4, ?5)",
1049 params![seq, k, key, prev, new],
1050 )
1051 .map_err(|e| e.to_string())?;
1052 }
1053 prune_saves(conn, seq, now, KEEP_SAVES, HARD_KEEP_SAVES)?;
1054 let tok = token(seq, &hash);
1055 conn.execute(
1056 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('data_hash', ?1)",
1057 params![tok],
1058 )
1059 .map_err(|e| e.to_string())?;
1060 Ok(tok)
1061}
1062
1063fn legacy_blob(conn: &Connection) -> Result<Option<String>, String> {
1066 conn.query_row("SELECT data FROM vault WHERE id = 1", [], |r| r.get(0))
1067 .optional()
1068 .map_err(|e| e.to_string())
1069}
1070
1071pub fn needs_migration(conn: &Connection) -> Result<bool, String> {
1073 Ok(legacy_blob(conn)?.is_some())
1074}
1075
1076fn backup_v1(conn: &Connection) {
1078 let Some(path) = conn.path().map(std::path::PathBuf::from) else {
1079 return;
1080 };
1081 if path.as_os_str().is_empty() {
1082 return;
1083 }
1084 let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1085 let mut bak = path.as_os_str().to_owned();
1086 bak.push(".v1.bak");
1087 let bak = std::path::PathBuf::from(bak);
1088 if !bak.exists() && std::fs::copy(&path, &bak).is_ok() {
1089 let _ = crate::restrict_to_owner(&bak);
1090 }
1091}
1092
1093pub fn migrate_in_txn(conn: &Connection, now: &str) -> Result<bool, String> {
1096 let Some(raw) = legacy_blob(conn)? else {
1097 return Ok(false);
1098 };
1099 let doc: Value =
1100 serde_json::from_str(&raw).map_err(|e| format!("legacy vault is unreadable: {e}"))?;
1101 conn.execute("DELETE FROM vault_rows", [])
1102 .map_err(|e| e.to_string())?;
1103 conn.execute("DELETE FROM vault_history", [])
1104 .map_err(|e| e.to_string())?;
1105 conn.execute("DELETE FROM vault_meta WHERE key = 'state_acc'", [])
1106 .map_err(|e| e.to_string())?;
1107 write(conn, &snapshot(conn)?, &split(doc), now)?;
1108 conn.execute("DELETE FROM vault WHERE id = 1", [])
1109 .map_err(|e| e.to_string())?;
1110 Ok(true)
1111}
1112
1113pub fn migrate_if_needed(conn: &Connection, now: &str) -> Result<(), String> {
1115 if !needs_migration(conn)? {
1116 return Ok(());
1117 }
1118 backup_v1(conn);
1119 conn.execute_batch("BEGIN IMMEDIATE")
1120 .map_err(|e| e.to_string())?;
1121 let r = migrate_in_txn(conn, now).and_then(|_| {
1122 conn.execute(
1123 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
1124 params![crate::VAULT_SCHEMA_VERSION.to_string()],
1125 )
1126 .map(|_| ())
1127 .map_err(|e| e.to_string())
1128 });
1129 match r {
1130 Ok(()) => conn.execute_batch("COMMIT").map_err(|e| e.to_string()),
1131 Err(e) => {
1132 let _ = conn.execute_batch("ROLLBACK");
1133 Err(e)
1134 }
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use crate::{load_vault, save_vault, vault_version, SaveCtx, CONFLICT_ERR};
1142 use serde_json::json;
1143
1144 fn open(tag: &str) -> (Connection, std::path::PathBuf) {
1145 let nanos = std::time::SystemTime::now()
1146 .duration_since(std::time::UNIX_EPOCH)
1147 .unwrap()
1148 .as_nanos();
1149 let dir =
1150 std::env::temp_dir().join(format!("vc-storage-{tag}-{}-{nanos}", std::process::id()));
1151 std::fs::create_dir_all(&dir).unwrap();
1152 let key = crate::derive_key("correct horse battery staple", b"0123456789abcdef").unwrap();
1153 let conn = crate::open_db(&dir.join("vault.db"), &key).unwrap();
1154 crate::init_schema(&conn).unwrap();
1155 (conn, dir)
1156 }
1157
1158 fn e(id: &str, provider: &str) -> Value {
1159 json!({ "id": id, "provider": provider, "api_key": format!("key-{id}") })
1160 }
1161
1162 fn save(conn: &Connection, doc: Value, base: Option<&str>) -> Result<String, String> {
1163 save_vault(
1164 conn,
1165 doc,
1166 SaveCtx {
1167 actor: None,
1168 expect_version: base,
1169 },
1170 )
1171 }
1172
1173 fn names(conn: &Connection) -> Vec<String> {
1174 load_vault(conn).unwrap().unwrap()["api_keys"]
1175 .as_array()
1176 .unwrap()
1177 .iter()
1178 .map(|x| x["provider"].as_str().unwrap().to_string())
1179 .collect()
1180 }
1181
1182 #[test]
1183 fn the_document_round_trips_including_unknown_top_level_keys_and_order() {
1184 let (conn, _d) = open("roundtrip");
1185 let doc = json!({
1186 "api_keys": [e("b", "B"), e("a", "A"), e("c", "C")],
1187 "projects": [{ "id": "p2", "name": "Two" }, { "id": "p1", "name": "One" }],
1188 "user_categories": ["x", "a/b"],
1189 "someFutureKey": { "nested": [1, 2, 3] }
1190 });
1191 save(&conn, doc.clone(), None).unwrap();
1192 let got = load_vault(&conn).unwrap().unwrap();
1193 assert_eq!(got, doc);
1194 }
1195
1196 #[test]
1197 fn a_save_writes_only_the_rows_that_changed() {
1198 let (conn, _d) = open("minimal");
1199 let v1 = save(
1200 &conn,
1201 json!({ "api_keys": [e("1", "A"), e("2", "B"), e("3", "C")] }),
1202 None,
1203 )
1204 .unwrap();
1205 save(
1206 &conn,
1207 json!({ "api_keys": [e("1", "A"), e("2", "B-edited"), e("3", "C")] }),
1208 Some(&v1),
1209 )
1210 .unwrap();
1211 let last: i64 = conn
1212 .query_row("SELECT MAX(seq) FROM vault_saves", [], |r| r.get(0))
1213 .unwrap();
1214 let changed: Vec<(String, String)> = conn
1215 .prepare("SELECT kind, key FROM vault_changes WHERE seq = ?1")
1216 .unwrap()
1217 .query_map(params![last], |r| Ok((r.get(0)?, r.get(1)?)))
1218 .unwrap()
1219 .map(|x| x.unwrap())
1220 .collect();
1221 assert_eq!(changed, vec![("entry".to_string(), "id\u{1}2".to_string())]);
1222 }
1223
1224 #[test]
1225 fn an_identical_save_creates_no_new_version() {
1226 let (conn, _d) = open("noop");
1227 let doc = json!({ "api_keys": [e("1", "A")] });
1228 let v1 = save(&conn, doc.clone(), None).unwrap();
1229 let v2 = save(&conn, doc, Some(&v1)).unwrap();
1230 assert_eq!(v1, v2);
1231 let n: i64 = conn
1232 .query_row("SELECT COUNT(*) FROM vault_saves", [], |r| r.get(0))
1233 .unwrap();
1234 assert_eq!(n, 1);
1235 }
1236
1237 #[test]
1238 fn two_writers_editing_different_entries_both_land() {
1239 let (conn, _d) = open("disjoint");
1240 let base = json!({ "api_keys": [e("1", "A"), e("2", "B")] });
1241 let v1 = save(&conn, base, None).unwrap();
1242 save(
1244 &conn,
1245 json!({ "api_keys": [e("1", "A-one"), e("2", "B")] }),
1246 Some(&v1),
1247 )
1248 .unwrap();
1249 let out = save(
1251 &conn,
1252 json!({ "api_keys": [e("1", "A"), e("2", "B-two")] }),
1253 Some(&v1),
1254 );
1255 assert!(out.is_ok(), "{out:?}");
1256 assert_eq!(names(&conn), vec!["A-one", "B-two"]);
1257 }
1258
1259 fn proj(id: &str, chunks: Value) -> Value {
1260 json!({ "id": id, "name": id, "chunks": chunks })
1261 }
1262 fn ch(id: &str, v: &str) -> Value {
1263 json!({ "id": id, "name": id, "value": v })
1264 }
1265 fn chunk_values(conn: &Connection, p: usize) -> Vec<String> {
1266 load_vault(conn).unwrap().unwrap()["projects"][p]["chunks"]
1267 .as_array()
1268 .unwrap()
1269 .iter()
1270 .map(|c| c["value"].as_str().unwrap().to_string())
1271 .collect()
1272 }
1273
1274 #[test]
1275 fn chunks_are_rows_of_their_own_and_the_document_round_trips() {
1276 let (conn, _d) = open("chunk-rows");
1277 let doc = json!({
1278 "api_keys": [],
1279 "projects": [
1280 proj("p1", json!([ch("c1", "a"), ch("c2", "b")])),
1281 proj("p2", json!([])),
1282 { "id": "p3", "name": "no chunks key" }
1283 ]
1284 });
1285 save(&conn, doc.clone(), None).unwrap();
1286 let rows: i64 = conn
1287 .query_row(
1288 "SELECT COUNT(*) FROM vault_rows WHERE kind='chunk'",
1289 [],
1290 |r| r.get(0),
1291 )
1292 .unwrap();
1293 assert_eq!(rows, 2);
1294 let got = load_vault(&conn).unwrap().unwrap();
1295 assert_eq!(got["projects"], doc["projects"]);
1296 }
1297
1298 #[test]
1299 fn inline_chunks_from_an_unconverted_vault_still_load() {
1300 let doc = json!({ "api_keys": [], "projects": [proj("p1", json!([ch("c1", "a")]))] });
1301 let ents = split(doc.clone());
1302 let mut legacy = ents
1304 .into_iter()
1305 .filter(|e| e.kind != KIND_CHUNK)
1306 .collect::<Vec<_>>();
1307 for e in legacy.iter_mut().filter(|e| e.kind == KIND_PROJECT) {
1308 e.data["chunks"] = json!([ch("c1", "a")]);
1309 }
1310 assert_eq!(join(legacy)["projects"], doc["projects"]);
1311 }
1312
1313 #[test]
1314 fn two_writers_editing_different_chunks_of_one_project_both_land() {
1315 let (conn, _d) = open("chunk-disjoint");
1316 let base = json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a"), ch("c2", "b")]))] });
1317 let v1 = save(&conn, base, None).unwrap();
1318 save(
1319 &conn,
1320 json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a-one"), ch("c2", "b")]))] }),
1321 Some(&v1),
1322 )
1323 .unwrap();
1324 let out = save(
1325 &conn,
1326 json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a"), ch("c2", "b-two")]))] }),
1327 Some(&v1),
1328 );
1329 assert!(out.is_ok(), "{out:?}");
1330 assert_eq!(chunk_values(&conn, 0), vec!["a-one", "b-two"]);
1331 }
1332
1333 #[test]
1334 fn two_writers_editing_the_same_chunk_conflict_and_name_it() {
1335 let (conn, _d) = open("chunk-conflict");
1336 let base = json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a")]))] });
1337 let v1 = save(&conn, base, None).unwrap();
1338 save(
1339 &conn,
1340 json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "one")]))] }),
1341 Some(&v1),
1342 )
1343 .unwrap();
1344 let err = save(
1345 &conn,
1346 json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "two")]))] }),
1347 Some(&v1),
1348 )
1349 .unwrap_err();
1350 assert!(err.starts_with(CONFLICT_ERR) && err.contains("c1"), "{err}");
1351 }
1352
1353 #[test]
1354 fn deleting_a_project_removes_its_chunk_rows_and_a_stale_chunk_add_is_dropped() {
1355 let (conn, _d) = open("chunk-orphan");
1356 let v1 = save(
1357 &conn,
1358 json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a")]))] }),
1359 None,
1360 )
1361 .unwrap();
1362 save(&conn, json!({ "api_keys": [], "projects": [] }), Some(&v1)).unwrap();
1364 save(
1365 &conn,
1366 json!({ "api_keys": [], "projects": [proj("p", json!([ch("c1", "a"), ch("c2", "new")]))] }),
1367 Some(&v1),
1368 )
1369 .unwrap();
1370 let rows: i64 = conn
1371 .query_row(
1372 "SELECT COUNT(*) FROM vault_rows WHERE kind='chunk'",
1373 [],
1374 |r| r.get(0),
1375 )
1376 .unwrap();
1377 assert_eq!(rows, 0, "no orphan chunk rows");
1378 assert!(verify(&conn).unwrap());
1379 }
1380
1381 #[test]
1382 fn change_history_is_kept_by_age_as_well_as_by_count() {
1383 let (conn, _d) = open("retention");
1384 for i in 0..12 {
1385 save(
1386 &conn,
1387 json!({ "api_keys": [e("1", &format!("v{i}"))] }),
1388 None,
1389 )
1390 .unwrap();
1391 }
1392 conn.execute(
1394 "UPDATE vault_saves SET at = '2000-01-01T00:00:00Z' WHERE seq <= 6",
1395 [],
1396 )
1397 .unwrap();
1398 let max: i64 = conn
1399 .query_row("SELECT MAX(seq) FROM vault_saves", [], |r| r.get(0))
1400 .unwrap();
1401 let now = crate::iso_now();
1402 prune_saves(&conn, max, &now, 4, 1000).unwrap();
1404 let left: i64 = conn
1405 .query_row("SELECT COUNT(*) FROM vault_saves", [], |r| r.get(0))
1406 .unwrap();
1407 assert_eq!(left, 6, "six old saves dropped, six recent kept");
1408 let low: i64 = conn
1409 .query_row(
1410 "SELECT COUNT(*) FROM vault_changes WHERE seq <= 6",
1411 [],
1412 |r| r.get(0),
1413 )
1414 .unwrap();
1415 assert_eq!(low, 0, "their change rows go with them");
1416 prune_saves(&conn, max, &now, 4, 3).unwrap();
1418 let left: i64 = conn
1419 .query_row("SELECT COUNT(*) FROM vault_saves", [], |r| r.get(0))
1420 .unwrap();
1421 assert_eq!(left, 3);
1422 assert_eq!(merge_window(&conn).unwrap().unwrap().1, 3);
1423 }
1424
1425 #[test]
1426 fn one_entrys_history_can_be_read_alone() {
1427 let (conn, _d) = open("hist-one");
1428 let mut a = e("a", "A");
1429 a["version_history"] = json!([{ "value": "old", "saved_at": "2026-01-01T00:00:00Z" }]);
1430 save(&conn, json!({ "api_keys": [a, e("b", "B")] }), None).unwrap();
1431 assert_eq!(
1432 entry_history(&conn, "a").unwrap().unwrap()[0]["value"],
1433 "old"
1434 );
1435 assert_eq!(entry_history(&conn, "b").unwrap().unwrap(), json!([]));
1436 assert!(entry_history(&conn, "missing").unwrap().is_none());
1437 }
1438
1439 #[test]
1440 fn a_stale_writer_neither_deletes_nor_overwrites_what_it_never_saw() {
1441 let (conn, _d) = open("unseen");
1442 let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1443 save(
1445 &conn,
1446 json!({ "api_keys": [e("1", "A"), e("2", "Added")] }),
1447 Some(&v1),
1448 )
1449 .unwrap();
1450 save(&conn, json!({ "api_keys": [e("1", "A-edit")] }), Some(&v1)).unwrap();
1452 assert_eq!(names(&conn), vec!["A-edit", "Added"]);
1453 }
1454
1455 #[test]
1456 fn deleting_an_untouched_entry_while_another_is_added_applies_both() {
1457 let (conn, _d) = open("delete-add");
1458 let v1 = save(
1459 &conn,
1460 json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1461 None,
1462 )
1463 .unwrap();
1464 save(
1465 &conn,
1466 json!({ "api_keys": [e("1", "A"), e("2", "B"), e("3", "C")] }),
1467 Some(&v1),
1468 )
1469 .unwrap();
1470 save(&conn, json!({ "api_keys": [e("1", "A")] }), Some(&v1)).unwrap();
1471 assert_eq!(names(&conn), vec!["A", "C"]);
1472 }
1473
1474 #[test]
1475 fn a_merged_save_says_so_and_a_writer_that_reloads_carries_on() {
1476 let (conn, _d) = open("merged-token");
1477 let v1 = save(
1478 &conn,
1479 json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1480 None,
1481 )
1482 .unwrap();
1483 save(
1485 &conn,
1486 json!({ "api_keys": [e("1", "A-theirs"), e("2", "B")] }),
1487 Some(&v1),
1488 )
1489 .unwrap();
1490 let t = save(
1492 &conn,
1493 json!({ "api_keys": [e("1", "A"), e("2", "B-mine")] }),
1494 Some(&v1),
1495 )
1496 .unwrap();
1497 let bare = t
1498 .strip_suffix(crate::MERGED_SUFFIX)
1499 .expect("a merge is marked");
1500 assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(bare));
1501 assert_eq!(names(&conn), vec!["A-theirs", "B-mine"]);
1503 let mut doc = load_vault(&conn).unwrap().unwrap();
1504 doc["api_keys"][1]["provider"] = json!("B-mine-2");
1505 save(&conn, doc, Some(bare)).unwrap();
1506 assert_eq!(names(&conn), vec!["A-theirs", "B-mine-2"]);
1507 }
1508
1509 #[test]
1510 fn the_marked_token_is_accepted_as_a_base() {
1511 let (conn, _d) = open("marked");
1512 let v1 = save(
1513 &conn,
1514 json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1515 None,
1516 )
1517 .unwrap();
1518 save(
1519 &conn,
1520 json!({ "api_keys": [e("1", "A-theirs"), e("2", "B")] }),
1521 Some(&v1),
1522 )
1523 .unwrap();
1524 let t = save(
1525 &conn,
1526 json!({ "api_keys": [e("1", "A"), e("2", "B-mine")] }),
1527 Some(&v1),
1528 )
1529 .unwrap();
1530 assert!(t.ends_with(crate::MERGED_SUFFIX));
1531 let mut doc = load_vault(&conn).unwrap().unwrap();
1532 doc["api_keys"][0]["api_key"] = json!("changed");
1533 assert!(save(&conn, doc, Some(&t)).is_ok());
1534 }
1535
1536 #[test]
1537 fn a_save_with_nothing_to_merge_returns_the_new_version() {
1538 let (conn, _d) = open("clean-token");
1539 let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1540 let v2 = save(&conn, json!({ "api_keys": [e("1", "A2")] }), Some(&v1)).unwrap();
1541 assert_ne!(v1, v2);
1542 assert!(!v2.contains('+'), "no merge happened: {v2}");
1543 assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(v2.as_str()));
1544 }
1545
1546 #[test]
1547 fn deleting_an_entry_someone_else_edited_is_a_conflict_that_names_it() {
1548 let (conn, _d) = open("delete-edited");
1549 let v1 = save(
1550 &conn,
1551 json!({ "api_keys": [e("1", "Keep"), e("2", "Contested")] }),
1552 None,
1553 )
1554 .unwrap();
1555 save(
1556 &conn,
1557 json!({ "api_keys": [e("1", "Keep"), e("2", "Contested-edited")] }),
1558 Some(&v1),
1559 )
1560 .unwrap();
1561 let err = save(&conn, json!({ "api_keys": [e("1", "Keep")] }), Some(&v1)).unwrap_err();
1562 assert!(err.starts_with(CONFLICT_ERR));
1563 assert!(err.contains("Contested-edited"), "{err}");
1564 assert_eq!(names(&conn), vec!["Keep", "Contested-edited"]);
1565 }
1566
1567 #[test]
1568 fn editing_an_entry_someone_else_deleted_is_a_conflict() {
1569 let (conn, _d) = open("edit-deleted");
1570 let v1 = save(
1571 &conn,
1572 json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1573 None,
1574 )
1575 .unwrap();
1576 save(&conn, json!({ "api_keys": [e("1", "A")] }), Some(&v1)).unwrap();
1577 let err = save(
1578 &conn,
1579 json!({ "api_keys": [e("1", "A"), e("2", "B-edit")] }),
1580 Some(&v1),
1581 )
1582 .unwrap_err();
1583 assert!(err.starts_with(CONFLICT_ERR), "{err}");
1584 }
1585
1586 #[test]
1587 fn two_writers_arriving_at_the_same_content_do_not_conflict() {
1588 let (conn, _d) = open("same");
1589 let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1590 save(&conn, json!({ "api_keys": [e("1", "Same")] }), Some(&v1)).unwrap();
1591 assert!(save(&conn, json!({ "api_keys": [e("1", "Same")] }), Some(&v1)).is_ok());
1592 }
1593
1594 #[test]
1595 fn projects_and_categories_merge_like_entries() {
1596 let (conn, _d) = open("projects");
1597 let v1 = save(
1598 &conn,
1599 json!({ "api_keys": [], "projects": [{ "id": "p1", "name": "One" }, { "id": "p2", "name": "Two" }], "user_categories": ["a"] }),
1600 None,
1601 )
1602 .unwrap();
1603 save(
1604 &conn,
1605 json!({ "api_keys": [], "projects": [{ "id": "p1", "name": "One-renamed" }, { "id": "p2", "name": "Two" }], "user_categories": ["a"] }),
1606 Some(&v1),
1607 )
1608 .unwrap();
1609 save(
1610 &conn,
1611 json!({ "api_keys": [], "projects": [{ "id": "p1", "name": "One" }, { "id": "p2", "name": "Two-renamed" }], "user_categories": ["a", "b"] }),
1612 Some(&v1),
1613 )
1614 .unwrap();
1615 let got = load_vault(&conn).unwrap().unwrap();
1616 assert_eq!(got["projects"][0]["name"], "One-renamed");
1617 assert_eq!(got["projects"][1]["name"], "Two-renamed");
1618 assert_eq!(got["user_categories"], json!(["a", "b"]));
1619 }
1620
1621 #[test]
1622 fn a_token_in_the_old_form_or_from_a_pruned_save_is_a_whole_vault_conflict() {
1623 let (conn, _d) = open("oldtoken");
1624 let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1625 save(&conn, json!({ "api_keys": [e("1", "A2")] }), Some(&v1)).unwrap();
1626 for bad in [
1627 "deadbeef".to_string(),
1628 "1.0000".to_string(),
1629 "x.y".to_string(),
1630 ] {
1631 let err = save(&conn, json!({ "api_keys": [e("9", "Z")] }), Some(&bad)).unwrap_err();
1632 assert!(err.starts_with(CONFLICT_ERR), "{bad}: {err}");
1633 }
1634 conn.execute("DELETE FROM vault_saves WHERE seq = 1", [])
1636 .unwrap();
1637 let err = save(&conn, json!({ "api_keys": [e("1", "mine")] }), Some(&v1)).unwrap_err();
1638 assert!(err.starts_with(CONFLICT_ERR), "{err}");
1639 }
1640
1641 #[test]
1642 fn history_lives_in_its_own_table_and_not_in_the_row() {
1643 let (conn, _d) = open("history");
1644 let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1645 let mut changed = e("1", "A");
1646 changed["api_key"] = json!("rotated");
1647 save(&conn, json!({ "api_keys": [changed] }), Some(&v1)).unwrap();
1648 let row: String = conn
1649 .query_row(
1650 "SELECT data FROM vault_rows WHERE kind = 'entry'",
1651 [],
1652 |r| r.get(0),
1653 )
1654 .unwrap();
1655 assert!(!row.contains("version_history"), "{row}");
1656 assert!(
1657 !row.contains("key-1"),
1658 "the old key belongs in the history table only"
1659 );
1660 let hist: String = conn
1661 .query_row("SELECT data FROM vault_history", [], |r| r.get(0))
1662 .unwrap();
1663 assert!(hist.contains("key-1"));
1664 let doc = load_vault(&conn).unwrap().unwrap();
1666 assert_eq!(doc["api_keys"][0]["version_history"][0]["value"], "key-1");
1667 }
1668
1669 #[test]
1670 fn deleting_an_entry_deletes_its_history() {
1671 let (conn, _d) = open("history-delete");
1672 let v1 = save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1673 let mut changed = e("1", "A");
1674 changed["api_key"] = json!("rotated");
1675 let v2 = save(&conn, json!({ "api_keys": [changed] }), Some(&v1)).unwrap();
1676 save(&conn, json!({ "api_keys": [] }), Some(&v2)).unwrap();
1677 let n: i64 = conn
1678 .query_row("SELECT COUNT(*) FROM vault_history", [], |r| r.get(0))
1679 .unwrap();
1680 assert_eq!(n, 0);
1681 }
1682
1683 #[test]
1684 fn legacy_entries_with_the_same_identity_both_survive() {
1685 let (conn, _d) = open("dupes");
1686 let doc = json!({ "api_keys": [
1687 { "provider": "Same", "api_key": "one" },
1688 { "provider": "Same", "api_key": "two" }
1689 ] });
1690 save(&conn, doc, None).unwrap();
1691 let got = load_vault(&conn).unwrap().unwrap();
1692 let keys: Vec<&str> = got["api_keys"]
1693 .as_array()
1694 .unwrap()
1695 .iter()
1696 .map(|x| x["api_key"].as_str().unwrap())
1697 .collect();
1698 assert_eq!(keys, vec!["one", "two"]);
1699 }
1700
1701 #[test]
1702 fn reordering_is_a_change_but_not_a_content_conflict() {
1703 let (conn, _d) = open("reorder");
1704 let v1 = save(
1705 &conn,
1706 json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1707 None,
1708 )
1709 .unwrap();
1710 let v2 = save(
1711 &conn,
1712 json!({ "api_keys": [e("2", "B"), e("1", "A")] }),
1713 Some(&v1),
1714 )
1715 .unwrap();
1716 assert_ne!(v1, v2);
1717 assert_eq!(names(&conn), vec!["B", "A"]);
1718 }
1719
1720 #[test]
1721 fn tampering_with_a_row_or_with_the_token_fails_integrity() {
1722 let (conn, _d) = open("verify");
1723 save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1724 assert!(crate::verify_vault_integrity(&conn).unwrap());
1725 conn.execute("UPDATE vault_rows SET pos = 7 WHERE kind = 'entry'", [])
1726 .unwrap();
1727 assert!(
1728 !crate::verify_vault_integrity(&conn).unwrap(),
1729 "a moved row changes the state hash"
1730 );
1731 }
1732
1733 fn plant_v1(conn: &Connection, doc: &Value) {
1736 conn.execute(
1737 "INSERT OR REPLACE INTO vault (id, data) VALUES (1, ?1)",
1738 params![doc.to_string()],
1739 )
1740 .unwrap();
1741 conn.execute(
1742 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1743 [],
1744 )
1745 .unwrap();
1746 conn.execute(
1747 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('data_hash', 'abc')",
1748 [],
1749 )
1750 .unwrap();
1751 }
1752
1753 #[test]
1754 fn a_v1_vault_is_converted_on_first_open_with_a_backup_and_nothing_lost() {
1755 let (conn, dir) = open("migrate");
1756 let doc = json!({
1757 "api_keys": [
1758 { "id": "1", "provider": "A", "api_key": "k", "version_history": [{ "value": "old", "saved_at": "2026-01-01T00:00:00Z" }] },
1759 { "id": "2", "provider": "B", "api_key": "k2" }
1760 ],
1761 "projects": [{ "id": "p", "name": "P", "chunks": [] }],
1762 "user_categories": ["c"],
1763 "extra": 1
1764 });
1765 plant_v1(&conn, &doc);
1766 let audit_before = crate::load_audit(&conn).unwrap().len();
1767
1768 let got = load_vault(&conn).unwrap().unwrap();
1769 assert_eq!(got, doc, "same document, new storage");
1770 assert_eq!(
1771 crate::vault_schema_version(&conn).unwrap(),
1772 Some(crate::VAULT_SCHEMA_VERSION)
1773 );
1774 assert!(
1775 dir.join("vault.db.v1.bak").exists(),
1776 "the one-way conversion is backed up first"
1777 );
1778 let blob: i64 = conn
1779 .query_row("SELECT COUNT(*) FROM vault", [], |r| r.get(0))
1780 .unwrap();
1781 assert_eq!(blob, 0, "no second copy of every secret is left behind");
1782 assert_eq!(
1783 crate::load_audit(&conn).unwrap().len(),
1784 audit_before,
1785 "a move is not an edit"
1786 );
1787 assert!(crate::verify_vault_integrity(&conn).unwrap());
1788 let h: i64 = conn
1790 .query_row("SELECT COUNT(*) FROM vault_history", [], |r| r.get(0))
1791 .unwrap();
1792 assert_eq!(h, 1);
1793 assert_eq!(load_vault(&conn).unwrap().unwrap(), doc);
1795 }
1796
1797 #[test]
1798 fn the_version_read_before_the_data_is_valid_after_a_conversion() {
1799 let (conn, _d) = open("v1-version-order");
1800 plant_v1(&conn, &json!({ "api_keys": [e("1", "A")] }));
1801 crate::ensure_current_schema(&conn).unwrap();
1802 let v = vault_version(&conn).unwrap().unwrap();
1803 let doc = load_vault(&conn).unwrap().unwrap();
1804 let mut edited = doc;
1805 edited["api_keys"][0]["provider"] = json!("A2");
1806 assert!(
1807 save(&conn, edited, Some(&v)).is_ok(),
1808 "the first save after an upgrade must not conflict"
1809 );
1810 }
1811
1812 #[test]
1813 fn saving_a_v1_vault_converts_it_in_the_same_transaction() {
1814 let (conn, _d) = open("migrate-save");
1815 plant_v1(&conn, &json!({ "api_keys": [e("1", "A")] }));
1816 let v = vault_version(&conn).unwrap().unwrap();
1817 assert_eq!(
1818 v, "abc",
1819 "before conversion the old token is still what is stored"
1820 );
1821 let out = save(
1822 &conn,
1823 json!({ "api_keys": [e("1", "A"), e("2", "B")] }),
1824 None,
1825 )
1826 .unwrap();
1827 assert_ne!(out, "abc");
1828 assert_eq!(names(&conn), vec!["A", "B"]);
1829 let blob: i64 = conn
1830 .query_row("SELECT COUNT(*) FROM vault", [], |r| r.get(0))
1831 .unwrap();
1832 assert_eq!(blob, 0);
1833 }
1834
1835 #[test]
1836 fn a_vault_with_rows_but_a_v1_stamp_and_no_blob_is_left_alone() {
1837 let (conn, _d) = open("stamp-only");
1838 save(&conn, json!({ "api_keys": [e("1", "A")] }), None).unwrap();
1839 conn.execute(
1840 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1841 [],
1842 )
1843 .unwrap();
1844 assert_eq!(names(&conn), vec!["A"]);
1845 }
1846
1847 #[test]
1851 #[ignore]
1852 fn measure_one_edit_against_the_blob() {
1853 use std::time::Instant;
1854 let (conn, _d) = open("bench");
1855 let n = 5000;
1856 let entries: Vec<Value> = (0..n)
1857 .map(|i| {
1858 json!({
1859 "id": format!("id-{i}"), "provider": format!("Provider {i}"),
1860 "api_key": "x".repeat(48), "description": "d".repeat(200),
1861 "tags": ["a", "b"], "extra_vars": [{"key": "K", "value": "v".repeat(64)}],
1862 "version_history": (0..3).map(|j| json!({"value": "o".repeat(48), "saved_at": format!("2026-01-0{}T00:00:00Z", j + 1)})).collect::<Vec<_>>()
1863 })
1864 })
1865 .collect();
1866 let mut doc = json!({ "api_keys": entries, "projects": [], "user_categories": [] });
1867 let t = Instant::now();
1868 let mut base = save(&conn, doc.clone(), None).unwrap();
1869 println!("first save of {n} entries: {:?}", t.elapsed());
1870
1871 doc["api_keys"][2500]["description"] = json!("edited");
1873 let t = Instant::now();
1874 base = save(&conn, doc.clone(), Some(&base)).unwrap();
1875 let v2 = t.elapsed();
1876 println!("v2: one edit in {n}: {v2:?}");
1877 let t = Instant::now();
1878 let _parts = split(doc.clone());
1879 println!(" split: {:?}", t.elapsed());
1880 conn.execute_batch(
1884 "CREATE TABLE IF NOT EXISTS old_blob (id INTEGER PRIMARY KEY, data TEXT)",
1885 )
1886 .unwrap();
1887 let first = serde_json::to_string(&doc).unwrap();
1888 conn.execute(
1889 "INSERT OR REPLACE INTO old_blob (id, data) VALUES (1, ?1)",
1890 params![first],
1891 )
1892 .unwrap();
1893 let t = Instant::now();
1894 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
1895 let old: String = conn
1896 .query_row("SELECT data FROM old_blob WHERE id = 1", [], |r| r.get(0))
1897 .unwrap();
1898 let old_doc: Value = serde_json::from_str(&old).unwrap();
1899 let old_map: HashMap<String, Value> = old_doc["api_keys"]
1900 .as_array()
1901 .unwrap()
1902 .iter()
1903 .map(|e| (crate::entry_ck(e), e.clone()))
1904 .collect();
1905 let _same = doc["api_keys"]
1906 .as_array()
1907 .unwrap()
1908 .iter()
1909 .filter(|e| old_map.contains_key(&crate::entry_ck(e)))
1910 .count();
1911 let raw = serde_json::to_string(&doc).unwrap();
1912 let _h = sha(&raw);
1913 conn.execute(
1914 "INSERT OR REPLACE INTO old_blob (id, data) VALUES (1, ?1)",
1915 params![raw],
1916 )
1917 .unwrap();
1918 conn.execute_batch("COMMIT").unwrap();
1919 let v1 = t.elapsed();
1920 println!(
1921 "v1 (read blob, parse, index, serialise, hash, rewrite; {} KB): {v1:?}",
1922 raw.len() / 1024
1923 );
1924
1925 let t = Instant::now();
1926 let loaded = load_vault(&conn).unwrap().unwrap();
1927 println!("load of {n} entries with history: {:?}", t.elapsed());
1928 let t = Instant::now();
1929 let lite = load_lite(&conn).unwrap().unwrap();
1930 println!("selective load without history: {:?}", t.elapsed());
1931 assert_eq!(
1932 loaded["api_keys"].as_array().unwrap().len(),
1933 lite["api_keys"].as_array().unwrap().len()
1934 );
1935 let _ = base;
1936 }
1937}