Skip to main content

kimberlite_migration/
lock.rs

1//! Migration lock file for integrity validation.
2
3use crate::{Error, MigrationFile, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::Path;
7
8/// Lock file entry for a migration.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct LockEntry {
11    /// Migration ID
12    pub id: u32,
13
14    /// Migration name
15    pub name: String,
16
17    /// SHA-256 checksum
18    pub checksum: String,
19}
20
21/// Migration lock file for tamper detection.
22///
23/// Stores checksums of all migrations to detect if they've been modified
24/// after being applied. This prevents data integrity issues from migrations
25/// being changed retroactively.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LockFile {
28    /// Version of lock file format
29    pub version: u32,
30
31    /// Locked migrations (stored as vec for TOML compatibility)
32    #[serde(rename = "migration")]
33    pub migrations: Vec<LockEntry>,
34}
35
36impl LockFile {
37    /// Creates a new empty lock file.
38    pub fn new() -> Self {
39        Self {
40            version: 1,
41            migrations: Vec::new(),
42        }
43    }
44
45    /// Loads lock file from disk.
46    pub fn load(path: &Path) -> Result<Self> {
47        if !path.exists() {
48            return Ok(Self::new());
49        }
50
51        let content = fs::read_to_string(path)?;
52
53        if content.trim().is_empty() {
54            return Ok(Self::new());
55        }
56
57        let lock_file: Self = toml::from_str(&content)?;
58
59        Ok(lock_file)
60    }
61
62    /// Saves lock file to disk.
63    pub fn save(&self, path: &Path) -> Result<()> {
64        // Ensure parent directory exists
65        if let Some(parent) = path.parent() {
66            fs::create_dir_all(parent)?;
67        }
68
69        let content = toml::to_string_pretty(self)?;
70        fs::write(path, content)?;
71
72        Ok(())
73    }
74
75    /// Adds a migration to the lock file.
76    pub fn lock(&mut self, file: &MigrationFile) {
77        let entry = LockEntry {
78            id: file.migration.id,
79            name: file.migration.name.clone(),
80            checksum: file.checksum.clone(),
81        };
82
83        // Remove existing entry if present
84        self.migrations.retain(|e| e.id != file.migration.id);
85        self.migrations.push(entry);
86
87        // Keep sorted by ID
88        self.migrations.sort_by_key(|e| e.id);
89    }
90
91    /// Validates migrations against lock file.
92    pub fn validate(&self, files: &[MigrationFile]) -> Result<()> {
93        for file in files {
94            if let Some(locked) = self.migrations.iter().find(|e| e.id == file.migration.id) {
95                // Check if checksum matches
96                if locked.checksum != file.checksum {
97                    return Err(Error::ChecksumMismatch {
98                        id: file.migration.id,
99                        expected: locked.checksum.clone(),
100                        actual: file.checksum.clone(),
101                    });
102                }
103            }
104        }
105
106        Ok(())
107    }
108
109    /// Checks if a migration is locked.
110    pub fn is_locked(&self, id: u32) -> bool {
111        self.migrations.iter().any(|e| e.id == id)
112    }
113
114    /// Updates lock file with new migrations.
115    pub fn update(&mut self, files: &[MigrationFile]) -> Result<()> {
116        // Validate existing locked migrations first
117        self.validate(files)?;
118
119        // Add new migrations
120        for file in files {
121            if !self.is_locked(file.migration.id) {
122                self.lock(file);
123            }
124        }
125
126        Ok(())
127    }
128}
129
130impl Default for LockFile {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::Migration;
140    use chrono::Utc;
141    use std::path::PathBuf;
142    use tempfile::TempDir;
143
144    fn create_test_migration_file(id: u32, name: &str, sql: &str) -> MigrationFile {
145        let migration = Migration {
146            id,
147            name: name.to_string(),
148            sql: sql.to_string(),
149            created_at: Utc::now(),
150            author: None,
151        };
152
153        let checksum = migration.checksum();
154
155        MigrationFile {
156            migration,
157            path: PathBuf::from(format!("{id:04}_{name}.sql")),
158            checksum,
159        }
160    }
161
162    #[test]
163    fn test_lock_file_creation() {
164        let lock = LockFile::new();
165
166        assert_eq!(lock.version, 1);
167        assert!(lock.migrations.is_empty());
168    }
169
170    #[test]
171    fn test_lock_migration() {
172        let mut lock = LockFile::new();
173        let file = create_test_migration_file(1, "test", "SELECT 1;");
174
175        lock.lock(&file);
176
177        assert!(lock.is_locked(1));
178        assert_eq!(
179            lock.migrations.iter().find(|e| e.id == 1).unwrap().name,
180            "test"
181        );
182    }
183
184    #[test]
185    fn test_validate_success() {
186        let mut lock = LockFile::new();
187        let file = create_test_migration_file(1, "test", "SELECT 1;");
188
189        lock.lock(&file);
190
191        // Validate with same file should succeed
192        assert!(lock.validate(&[file]).is_ok());
193    }
194
195    #[test]
196    fn test_validate_checksum_mismatch() {
197        let mut lock = LockFile::new();
198        let file1 = create_test_migration_file(1, "test", "SELECT 1;");
199
200        lock.lock(&file1);
201
202        // Different SQL = different checksum
203        let file2 = create_test_migration_file(1, "test", "SELECT 2;");
204
205        let result = lock.validate(&[file2]);
206
207        assert!(matches!(result, Err(Error::ChecksumMismatch { .. })));
208    }
209
210    #[test]
211    fn test_update_lock_file() {
212        let mut lock = LockFile::new();
213        let file1 = create_test_migration_file(1, "first", "SELECT 1;");
214
215        lock.lock(&file1);
216
217        // Add second migration
218        let file2 = create_test_migration_file(2, "second", "SELECT 2;");
219        lock.update(&[file1.clone(), file2.clone()]).unwrap();
220
221        assert!(lock.is_locked(1));
222        assert!(lock.is_locked(2));
223    }
224
225    #[test]
226    fn test_save_and_load() {
227        let temp = TempDir::new().unwrap();
228        let path = temp.path().join(".lock");
229
230        let mut lock = LockFile::new();
231        let file = create_test_migration_file(1, "test", "SELECT 1;");
232        lock.lock(&file);
233
234        // Save
235        lock.save(&path).unwrap();
236        assert!(path.exists());
237
238        // Load
239        let loaded = LockFile::load(&path).unwrap();
240
241        assert_eq!(loaded.version, 1);
242        assert_eq!(loaded.migrations.len(), 1);
243        assert!(loaded.is_locked(1));
244    }
245
246    #[test]
247    fn test_load_nonexistent_file() {
248        let temp = TempDir::new().unwrap();
249        let path = temp.path().join(".lock");
250
251        let lock = LockFile::load(&path).unwrap();
252
253        assert!(lock.migrations.is_empty());
254    }
255}