Skip to main content

ferrous_forge/config/locking/
mod.rs

1//! Configuration locking system
2//!
3//! Provides mandatory locking mechanism for critical configuration values.
4//! Once locked, values cannot be changed without explicit unlock with justification.
5//!
6//! @task T015
7//! @epic T014
8
9use crate::config::hierarchy::ConfigLevel;
10use crate::{Error, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use tokio::fs;
16use tracing::{info, warn};
17
18/// Audit logging for lock/unlock operations
19pub mod audit_log;
20/// Configuration change validator
21pub mod validator;
22
23pub use validator::ConfigValidator;
24
25/// Configuration lock entry with metadata
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LockEntry {
28    /// The locked value
29    pub value: String,
30    /// When the lock was created
31    pub locked_at: DateTime<Utc>,
32    /// Who/what created the lock (user, system, etc.)
33    pub locked_by: String,
34    /// Reason for locking
35    pub reason: String,
36    /// Configuration level where lock is set
37    pub level: ConfigLevel,
38}
39
40impl LockEntry {
41    /// Create a new lock entry
42    pub fn new(value: impl Into<String>, reason: impl Into<String>, level: ConfigLevel) -> Self {
43        Self {
44            value: value.into(),
45            locked_at: Utc::now(),
46            locked_by: whoami::username(),
47            reason: reason.into(),
48            level,
49        }
50    }
51}
52
53/// Locked configuration storage
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct LockedConfig {
56    /// Map of configuration keys to their lock entries
57    pub locks: HashMap<String, LockEntry>,
58    /// Version of the lock file format
59    pub version: String,
60}
61
62impl LockedConfig {
63    /// Create a new empty locked config
64    pub fn new() -> Self {
65        Self {
66            locks: HashMap::new(),
67            version: "1.0.0".to_string(),
68        }
69    }
70
71    /// Load locked configuration from a specific level
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if reading or parsing the lock file fails.
76    pub async fn load_from_level(level: ConfigLevel) -> Result<Option<Self>> {
77        let path = Self::lock_file_path_for_level(level)?;
78
79        if !path.exists() {
80            return Ok(None);
81        }
82
83        let contents = fs::read_to_string(&path).await.map_err(|e| {
84            Error::config(format!(
85                "Failed to read {} lock file: {}",
86                level.display_name(),
87                e
88            ))
89        })?;
90
91        let locked: LockedConfig = toml::from_str(&contents).map_err(|e| {
92            Error::config(format!(
93                "Failed to parse {} lock file: {}",
94                level.display_name(),
95                e
96            ))
97        })?;
98
99        info!(
100            "Loaded {} locks from {} level",
101            locked.locks.len(),
102            level.display_name()
103        );
104        Ok(Some(locked))
105    }
106
107    /// Save locked configuration to a specific level
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if serialization fails or the file cannot be written.
112    pub async fn save_to_level(&self, level: ConfigLevel) -> Result<()> {
113        let path = Self::lock_file_path_for_level(level)?;
114
115        // Ensure parent directory exists
116        if let Some(parent) = path.parent() {
117            fs::create_dir_all(parent).await.map_err(|e| {
118                Error::config(format!(
119                    "Failed to create directory for {} lock file: {}",
120                    level.display_name(),
121                    e
122                ))
123            })?;
124        }
125
126        let contents = toml::to_string_pretty(self)
127            .map_err(|e| Error::config(format!("Failed to serialize lock file: {}", e)))?;
128
129        fs::write(&path, contents).await.map_err(|e| {
130            Error::config(format!(
131                "Failed to write {} lock file: {}",
132                level.display_name(),
133                e
134            ))
135        })?;
136
137        info!(
138            "Saved {} lock file to {}",
139            level.display_name(),
140            path.display()
141        );
142        Ok(())
143    }
144
145    /// Get the lock file path for a specific level
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the path cannot be determined.
150    pub fn lock_file_path_for_level(level: ConfigLevel) -> Result<PathBuf> {
151        match level {
152            ConfigLevel::System => Ok(PathBuf::from("/etc/ferrous-forge/locked.toml")),
153            ConfigLevel::User => {
154                let config_dir = dirs::config_dir()
155                    .ok_or_else(|| Error::config("Could not find config directory"))?;
156                Ok(config_dir.join("ferrous-forge").join("locked.toml"))
157            }
158            ConfigLevel::Project => Ok(PathBuf::from(".forge/locked.toml")),
159        }
160    }
161
162    /// Check if a key is locked
163    pub fn is_locked(&self, key: &str) -> bool {
164        self.locks.contains_key(key)
165    }
166
167    /// Get lock entry for a key
168    pub fn get_lock(&self, key: &str) -> Option<&LockEntry> {
169        self.locks.get(key)
170    }
171
172    /// Lock a configuration key
173    pub fn lock(&mut self, key: impl Into<String>, entry: LockEntry) {
174        let key = key.into();
175        self.locks.insert(key, entry);
176    }
177
178    /// Unlock a configuration key
179    pub fn unlock(&mut self, key: &str) -> Option<LockEntry> {
180        self.locks.remove(key)
181    }
182
183    /// List all locked keys
184    pub fn list_locks(&self) -> Vec<(&String, &LockEntry)> {
185        self.locks.iter().collect()
186    }
187}
188
189/// Hierarchical lock manager that respects precedence
190pub struct HierarchicalLockManager {
191    /// System-level locks (lowest priority)
192    system: Option<LockedConfig>,
193    /// User-level locks
194    user: Option<LockedConfig>,
195    /// Project-level locks (highest priority)
196    project: Option<LockedConfig>,
197}
198
199impl HierarchicalLockManager {
200    /// Load locks from all levels
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if reading or parsing any lock file fails.
205    pub async fn load() -> Result<Self> {
206        let system = LockedConfig::load_from_level(ConfigLevel::System).await?;
207        let user = LockedConfig::load_from_level(ConfigLevel::User).await?;
208        let project = LockedConfig::load_from_level(ConfigLevel::Project).await?;
209
210        Ok(Self {
211            system,
212            user,
213            project,
214        })
215    }
216
217    /// Check if a key is locked at any level
218    ///
219    /// Returns the most specific lock (project > user > system)
220    #[allow(clippy::collapsible_if)]
221    pub fn is_locked(&self, key: &str) -> Option<(ConfigLevel, &LockEntry)> {
222        // Check in order of precedence (highest first)
223        if let Some(project) = &self.project {
224            if let Some(entry) = project.get_lock(key) {
225                return Some((ConfigLevel::Project, entry));
226            }
227        }
228
229        if let Some(user) = &self.user {
230            if let Some(entry) = user.get_lock(key) {
231                return Some((ConfigLevel::User, entry));
232            }
233        }
234
235        if let Some(system) = &self.system {
236            if let Some(entry) = system.get_lock(key) {
237                return Some((ConfigLevel::System, entry));
238            }
239        }
240
241        None
242    }
243
244    /// Check if a specific level has a lock
245    pub fn is_locked_at_level(&self, key: &str, level: ConfigLevel) -> Option<&LockEntry> {
246        let locks = match level {
247            ConfigLevel::System => self.system.as_ref(),
248            ConfigLevel::User => self.user.as_ref(),
249            ConfigLevel::Project => self.project.as_ref(),
250        };
251
252        locks.and_then(|l| l.get_lock(key))
253    }
254
255    /// Get all locks merged with proper precedence
256    pub fn get_effective_locks(&self) -> HashMap<String, (ConfigLevel, LockEntry)> {
257        let mut effective = HashMap::new();
258
259        // Apply in order of precedence (lowest to highest)
260        if let Some(system) = &self.system {
261            for (key, entry) in &system.locks {
262                effective.insert(key.clone(), (ConfigLevel::System, entry.clone()));
263            }
264        }
265
266        if let Some(user) = &self.user {
267            for (key, entry) in &user.locks {
268                effective.insert(key.clone(), (ConfigLevel::User, entry.clone()));
269            }
270        }
271
272        if let Some(project) = &self.project {
273            for (key, entry) in &project.locks {
274                effective.insert(key.clone(), (ConfigLevel::Project, entry.clone()));
275            }
276        }
277
278        effective
279    }
280
281    /// Lock a key at a specific level
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if saving the lock file fails.
286    #[allow(clippy::collapsible_if)]
287    pub async fn lock(
288        &mut self,
289        key: impl Into<String>,
290        value: impl Into<String>,
291        reason: impl Into<String>,
292        level: ConfigLevel,
293    ) -> Result<()> {
294        let key = key.into();
295        let entry = LockEntry::new(value, reason, level);
296
297        // Check if already locked at same or higher level
298        if let Some((existing_level, _)) = self.is_locked(&key) {
299            if existing_level >= level {
300                warn!(
301                    "Key '{}' is already locked at {} level",
302                    key,
303                    existing_level.display_name()
304                );
305                return Err(Error::config(format!(
306                    "Key '{}' is already locked at {} level",
307                    key,
308                    existing_level.display_name()
309                )));
310            }
311        }
312
313        // Get or create the config for this level
314        let locks = match level {
315            ConfigLevel::System => &mut self.system,
316            ConfigLevel::User => &mut self.user,
317            ConfigLevel::Project => &mut self.project,
318        };
319
320        if locks.is_none() {
321            *locks = Some(LockedConfig::new());
322        }
323
324        if let Some(config) = locks {
325            config.lock(key.clone(), entry);
326            config.save_to_level(level).await?;
327
328            info!("Locked key '{}' at {} level", key, level.display_name());
329        }
330
331        Ok(())
332    }
333
334    /// Unlock a key at a specific level
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the key is not locked at this level or if saving fails.
339    pub async fn unlock(
340        &mut self,
341        key: &str,
342        level: ConfigLevel,
343        reason: &str,
344    ) -> Result<LockEntry> {
345        // Verify the lock exists at this exact level
346        let locks = match level {
347            ConfigLevel::System => &mut self.system,
348            ConfigLevel::User => &mut self.user,
349            ConfigLevel::Project => &mut self.project,
350        };
351
352        let config = locks.as_mut().ok_or_else(|| {
353            Error::config(format!(
354                "No locks defined at {} level",
355                level.display_name()
356            ))
357        })?;
358
359        let entry = config.unlock(key).ok_or_else(|| {
360            Error::config(format!(
361                "Key '{}' is not locked at {} level",
362                key,
363                level.display_name()
364            ))
365        })?;
366
367        // Save the updated locks
368        config.save_to_level(level).await?;
369
370        info!(
371            "Unlocked key '{}' at {} level. Reason: {}",
372            key,
373            level.display_name(),
374            reason
375        );
376
377        // Audit log the unlock operation
378        audit_log::log_unlock(key, &entry, level, reason).await?;
379
380        Ok(entry)
381    }
382
383    /// Get lock status report
384    pub fn status_report(&self) -> String {
385        let mut report = String::from("Configuration Lock Status:\n\n");
386
387        let effective = self.get_effective_locks();
388
389        if effective.is_empty() {
390            report.push_str("No configuration values are currently locked.\n");
391            return report;
392        }
393
394        report.push_str(&format!("Total locked keys: {}\n\n", effective.len()));
395
396        for (key, (level, entry)) in effective {
397            report.push_str(&format!(
398                "  {}: {} (locked at {} level)\n",
399                key,
400                entry.value,
401                level.display_name()
402            ));
403            report.push_str(&format!(
404                "    Locked by: {} at {}\n",
405                entry.locked_by, entry.locked_at
406            ));
407            report.push_str(&format!("    Reason: {}\n\n", entry.reason));
408        }
409
410        report
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_lock_entry_creation() {
420        let entry = LockEntry::new("2024", "Required for project", ConfigLevel::Project);
421        assert_eq!(entry.value, "2024");
422        assert_eq!(entry.reason, "Required for project");
423        assert_eq!(entry.level, ConfigLevel::Project);
424    }
425
426    #[test]
427    fn test_locked_config_lock_unlock() {
428        let mut config = LockedConfig::new();
429        assert!(!config.is_locked("edition"));
430
431        let entry = LockEntry::new("2024", "Required", ConfigLevel::User);
432        config.lock("edition", entry.clone());
433        assert!(config.is_locked("edition"));
434        assert_eq!(config.get_lock("edition").unwrap().value, "2024");
435
436        let removed = config.unlock("edition");
437        assert!(removed.is_some());
438        assert!(!config.is_locked("edition"));
439    }
440
441    #[test]
442    fn test_locked_config_list_locks() {
443        let mut config = LockedConfig::new();
444        let entry1 = LockEntry::new("2024", "Required", ConfigLevel::User);
445        let entry2 = LockEntry::new("1.85", "Required", ConfigLevel::User);
446
447        config.lock("edition", entry1);
448        config.lock("rust-version", entry2);
449
450        let locks = config.list_locks();
451        assert_eq!(locks.len(), 2);
452    }
453
454    #[test]
455    fn test_hierarchical_lock_precedence() {
456        let mut system = LockedConfig::new();
457        let mut user = LockedConfig::new();
458        let mut project = LockedConfig::new();
459
460        system.lock(
461            "edition",
462            LockEntry::new("2021", "System default", ConfigLevel::System),
463        );
464        user.lock(
465            "edition",
466            LockEntry::new("2024", "User preference", ConfigLevel::User),
467        );
468        project.lock(
469            "rust-version",
470            LockEntry::new("1.88", "Project requirement", ConfigLevel::Project),
471        );
472
473        let manager = HierarchicalLockManager {
474            system: Some(system),
475            user: Some(user),
476            project: Some(project),
477        };
478
479        // Project-level should override system/user for edition
480        let result = manager.is_locked("edition");
481        assert!(result.is_some());
482        let (level, entry) = result.unwrap();
483        assert_eq!(level, ConfigLevel::User);
484        assert_eq!(entry.value, "2024");
485
486        // Project should take precedence for rust-version
487        let result = manager.is_locked("rust-version");
488        assert!(result.is_some());
489        let (level, entry) = result.unwrap();
490        assert_eq!(level, ConfigLevel::Project);
491        assert_eq!(entry.value, "1.88");
492    }
493}