Skip to main content

tsafe_core/
migrate.rs

1//! Safe schema upgrade path for vault files.
2//!
3//! When tsafe loads a vault whose `_schema` version is older than the
4//! current one, `migrate::run` applies transformations in order and writes the
5//! upgraded file back to disk atomically.  A snapshot is taken *before* any
6//! mutation so there is always a rollback point.
7//!
8//! Adding a new migration:
9//!  1. Increment `CURRENT_SCHEMA` to the new version string.
10//!  2. Push a new `Migration` entry into the `MIGRATIONS` slice.
11//!  3. Write a pure `fn(VaultFile) -> SafeResult<VaultFile>` that performs the
12//!     structural change.  No I/O inside the transform function.
13
14use crate::errors::{SafeError, SafeResult};
15use crate::snapshot;
16use crate::vault::VaultFile;
17use std::path::Path;
18
19/// The schema version this build writes.
20pub 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
28/// All known migrations in application order.
29/// Extend this slice when a new schema version is introduced.
30static MIGRATIONS: &[Migration] = &[];
31
32/// Check whether `file` needs upgrading and, if so, apply all pending
33/// migrations, write the result back to `path`, and return `true`.
34/// Returns `false` when the schema is already current.
35/// Returns `Err` when the schema is unknown or a transform fails.
36pub 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    // Unknown schema that we have no migration path for.
42    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    // Snapshot before mutating.
59    if path.exists() {
60        let _ = snapshot::take(path, profile, snapshot::DEFAULT_SNAPSHOT_KEEP);
61    }
62
63    // Apply transforms in order.
64    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    // Persist upgraded vault atomically.
72    let json = serde_json::to_string_pretty(&current)?;
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// ── tests ────────────────────────────────────────────────────────────────────
81
82#[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}