1use crate::errors::{SafeError, SafeResult};
15use crate::snapshot;
16use crate::vault::VaultFile;
17use std::path::Path;
18
19pub const CURRENT_SCHEMA: &str = "tsafe/vault/v1";
21
22struct Migration {
23 from: &'static str,
24 to: &'static str,
25 apply: fn(VaultFile) -> SafeResult<VaultFile>,
26}
27
28static MIGRATIONS: &[Migration] = &[];
31
32pub fn run(path: &Path, file: VaultFile, profile: &str) -> SafeResult<(VaultFile, bool)> {
37 if file.schema == CURRENT_SCHEMA {
38 return Ok((file, false));
39 }
40
41 let mut chain: Vec<&Migration> = Vec::new();
43 let mut cursor = file.schema.as_str();
44 loop {
45 if cursor == CURRENT_SCHEMA {
46 break;
47 }
48 let step = MIGRATIONS
49 .iter()
50 .find(|m| m.from == cursor)
51 .ok_or_else(|| SafeError::MigrationFailed {
52 reason: format!("no migration path from schema '{cursor}' to '{CURRENT_SCHEMA}'"),
53 })?;
54 chain.push(step);
55 cursor = step.to;
56 }
57
58 if path.exists() {
60 let _ = snapshot::take(path, profile, snapshot::DEFAULT_SNAPSHOT_KEEP);
61 }
62
63 let mut current = file;
65 for step in chain {
66 current = (step.apply)(current).map_err(|e| SafeError::MigrationFailed {
67 reason: format!("migration {} → {}: {e}", step.from, step.to),
68 })?;
69 }
70
71 let json = serde_json::to_string_pretty(¤t)?;
73 let tmp = path.with_extension("vault.migrate.tmp");
74 std::fs::write(&tmp, &json)?;
75 std::fs::rename(&tmp, path)?;
76
77 Ok((current, true))
78}
79
80#[cfg(test)]
83mod tests {
84 use super::*;
85 use crate::vault::{KdfParams, VaultChallenge, VaultFile};
86 use chrono::Utc;
87 use std::collections::HashMap;
88 use tempfile::tempdir;
89
90 fn dummy_file(schema: &str) -> VaultFile {
91 VaultFile {
92 schema: schema.to_string(),
93 kdf: KdfParams {
94 algorithm: "argon2id".into(),
95 m_cost: 65536,
96 t_cost: 3,
97 p_cost: 4,
98 salt: "AAAA".into(),
99 },
100 cipher: "xchacha20poly1305".into(),
101 vault_challenge: VaultChallenge {
102 nonce: "AAAA".into(),
103 ciphertext: "AAAA".into(),
104 },
105 created_at: Utc::now(),
106 updated_at: Utc::now(),
107 secrets: HashMap::new(),
108 age_recipients: Vec::new(),
109 wrapped_dek: None,
110 }
111 }
112
113 #[test]
114 fn current_schema_is_noop() {
115 let tmp = tempdir().unwrap();
116 let path = tmp.path().join("test.vault");
117 let file = dummy_file(CURRENT_SCHEMA);
118 let (_, changed) = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
119 run(&path, file, "test").unwrap()
120 });
121 assert!(!changed);
122 }
123
124 #[test]
125 fn unknown_schema_errors() {
126 let tmp = tempdir().unwrap();
127 let path = tmp.path().join("unknown.vault");
128 let file = dummy_file("unknown/vault/v99");
129 let err = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
130 run(&path, file, "unknown").unwrap_err()
131 });
132 assert!(matches!(err, SafeError::MigrationFailed { .. }));
133 }
134}