tsafe-core 1.0.9

Cryptographic vault engine for tsafe — consume this crate to build tools on top.
Documentation
//! Safe schema upgrade path for vault files.
//!
//! When tsafe loads a vault whose `_schema` version is older than the
//! current one, `migrate::run` applies transformations in order and writes the
//! upgraded file back to disk atomically.  A snapshot is taken *before* any
//! mutation so there is always a rollback point.
//!
//! Adding a new migration:
//!  1. Increment `CURRENT_SCHEMA` to the new version string.
//!  2. Push a new `Migration` entry into the `MIGRATIONS` slice.
//!  3. Write a pure `fn(VaultFile) -> SafeResult<VaultFile>` that performs the
//!     structural change.  No I/O inside the transform function.

use crate::errors::{SafeError, SafeResult};
use crate::snapshot;
use crate::vault::VaultFile;
use std::path::Path;

/// The schema version this build writes.
pub const CURRENT_SCHEMA: &str = "tsafe/vault/v1";

struct Migration {
    from: &'static str,
    to: &'static str,
    apply: fn(VaultFile) -> SafeResult<VaultFile>,
}

/// All known migrations in application order.
/// Extend this slice when a new schema version is introduced.
static MIGRATIONS: &[Migration] = &[];

/// Check whether `file` needs upgrading and, if so, apply all pending
/// migrations, write the result back to `path`, and return `true`.
/// Returns `false` when the schema is already current.
/// Returns `Err` when the schema is unknown or a transform fails.
pub fn run(path: &Path, file: VaultFile, profile: &str) -> SafeResult<(VaultFile, bool)> {
    if file.schema == CURRENT_SCHEMA {
        return Ok((file, false));
    }

    // Unknown schema that we have no migration path for.
    let mut chain: Vec<&Migration> = Vec::new();
    let mut cursor = file.schema.as_str();
    loop {
        if cursor == CURRENT_SCHEMA {
            break;
        }
        let step = MIGRATIONS
            .iter()
            .find(|m| m.from == cursor)
            .ok_or_else(|| SafeError::MigrationFailed {
                reason: format!("no migration path from schema '{cursor}' to '{CURRENT_SCHEMA}'"),
            })?;
        chain.push(step);
        cursor = step.to;
    }

    // Snapshot before mutating.
    if path.exists() {
        let _ = snapshot::take(path, profile, snapshot::DEFAULT_SNAPSHOT_KEEP);
    }

    // Apply transforms in order.
    let mut current = file;
    for step in chain {
        current = (step.apply)(current).map_err(|e| SafeError::MigrationFailed {
            reason: format!("migration {}{}: {e}", step.from, step.to),
        })?;
    }

    // Persist upgraded vault atomically.
    let json = serde_json::to_string_pretty(&current)?;
    let tmp = path.with_extension("vault.migrate.tmp");
    std::fs::write(&tmp, &json)?;
    std::fs::rename(&tmp, path)?;

    Ok((current, true))
}

// ── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vault::{KdfParams, VaultChallenge, VaultFile};
    use chrono::Utc;
    use std::collections::HashMap;
    use tempfile::tempdir;

    fn dummy_file(schema: &str) -> VaultFile {
        VaultFile {
            schema: schema.to_string(),
            kdf: KdfParams {
                algorithm: "argon2id".into(),
                m_cost: 65536,
                t_cost: 3,
                p_cost: 4,
                salt: "AAAA".into(),
            },
            cipher: "xchacha20poly1305".into(),
            vault_challenge: VaultChallenge {
                nonce: "AAAA".into(),
                ciphertext: "AAAA".into(),
            },
            created_at: Utc::now(),
            updated_at: Utc::now(),
            secrets: HashMap::new(),
            age_recipients: Vec::new(),
            wrapped_dek: None,
        }
    }

    #[test]
    fn current_schema_is_noop() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("test.vault");
        let file = dummy_file(CURRENT_SCHEMA);
        let (_, changed) = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            run(&path, file, "test").unwrap()
        });
        assert!(!changed);
    }

    #[test]
    fn unknown_schema_errors() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("unknown.vault");
        let file = dummy_file("unknown/vault/v99");
        let err = temp_env::with_var("TSAFE_VAULT_DIR", tmp.path().to_str(), || {
            run(&path, file, "unknown").unwrap_err()
        });
        assert!(matches!(err, SafeError::MigrationFailed { .. }));
    }
}