Skip to main content

licenz_core/
state_manager.rs

1//! Multi-location state management for tamper resistance
2//!
3//! This module stores license state in multiple locations to detect
4//! deletion attacks on clock manipulation detection.
5//!
6//! # Security Witness Pattern
7//!
8//! This module provides **attestation** about state file integrity.
9//! It detects and reports issues but does not automatically repair them.
10//! A policy layer should decide how to respond.
11
12use crate::anti_tamper::LicenseState;
13use crate::error::{LicenseError, Result};
14use sha2::{Digest, Sha256};
15use std::path::PathBuf;
16
17/// Observations about state file storage locations.
18#[derive(Debug, Clone, Default)]
19pub struct StateObservations {
20    pub valid_locations: Vec<PathBuf>,
21    pub missing_locations: Vec<PathBuf>,
22    pub corrupted_locations: Vec<PathBuf>,
23    pub error_locations: Vec<PathBuf>,
24}
25
26impl StateObservations {
27    pub fn has_inconsistency(&self) -> bool {
28        !self.missing_locations.is_empty() || !self.corrupted_locations.is_empty()
29    }
30
31    pub fn has_valid_state(&self) -> bool {
32        !self.valid_locations.is_empty()
33    }
34
35    pub fn total_locations(&self) -> usize {
36        self.valid_locations.len()
37            + self.missing_locations.len()
38            + self.corrupted_locations.len()
39            + self.error_locations.len()
40    }
41}
42
43/// Manages license state across multiple storage locations
44pub struct StateManager {
45    paths: Vec<PathBuf>,
46    state_integrity_key: [u8; 32],
47}
48
49impl StateManager {
50    /// Create a new state manager for a license (`state_integrity_key` must match save/load).
51    pub fn new(license_id: &str, state_integrity_key: [u8; 32]) -> Self {
52        let license_hash = sha256_short(license_id);
53
54        let mut paths = Vec::new();
55
56        if let Some(data_dir) = dirs_next::data_local_dir() {
57            paths.push(
58                data_dir
59                    .join(".licenz")
60                    .join(format!("{}.state", &license_hash)),
61            );
62        }
63
64        if let Some(home_dir) = dirs_next::home_dir() {
65            paths.push(home_dir.join(format!(".lz_{}", &license_hash[..12])));
66        }
67
68        let temp_dir = std::env::temp_dir();
69        paths.push(temp_dir.join(format!("lzs_{}.dat", &license_hash[..16])));
70
71        if let Some(config_dir) = dirs_next::config_dir() {
72            paths.push(
73                config_dir
74                    .join("licenz")
75                    .join(format!("{}.dat", &license_hash[..8])),
76            );
77        }
78
79        Self {
80            paths,
81            state_integrity_key,
82        }
83    }
84
85    /// Create with custom paths (for testing)
86    pub fn with_paths(
87        _license_id: &str,
88        paths: Vec<PathBuf>,
89        state_integrity_key: [u8; 32],
90    ) -> Self {
91        Self {
92            paths,
93            state_integrity_key,
94        }
95    }
96
97    pub fn load(&self, license_id: &str) -> Result<Option<LicenseState>> {
98        let (state, _observations) = self.load_with_observations(license_id)?;
99        Ok(state)
100    }
101
102    pub fn load_with_observations(
103        &self,
104        license_id: &str,
105    ) -> Result<(Option<LicenseState>, StateObservations)> {
106        let mut best_state: Option<LicenseState> = None;
107        let mut observations = StateObservations::default();
108        let key = &self.state_integrity_key;
109
110        for path in &self.paths {
111            match LicenseState::load(path, license_id, key) {
112                Ok(Some(state)) => {
113                    observations.valid_locations.push(path.clone());
114
115                    match &best_state {
116                        None => best_state = Some(state),
117                        Some(existing) if state.validation_count > existing.validation_count => {
118                            best_state = Some(state);
119                        }
120                        _ => {}
121                    }
122                }
123                Ok(None) => {
124                    observations.missing_locations.push(path.clone());
125                }
126                Err(LicenseError::StateFileTampered) => {
127                    observations.corrupted_locations.push(path.clone());
128                    tracing::debug!("Corrupted state file detected at {:?}", path);
129                }
130                Err(_) => {
131                    observations.error_locations.push(path.clone());
132                }
133            }
134        }
135
136        if !observations.corrupted_locations.is_empty() {
137            tracing::debug!(
138                "Found {} corrupted state files",
139                observations.corrupted_locations.len()
140            );
141        }
142
143        if observations.has_inconsistency() {
144            tracing::debug!(
145                "State file inconsistency: {} valid, {} missing, {} corrupted of {} total",
146                observations.valid_locations.len(),
147                observations.missing_locations.len(),
148                observations.corrupted_locations.len(),
149                self.paths.len()
150            );
151        }
152
153        Ok((best_state, observations))
154    }
155
156    /// Repair missing or unreadable state files using the canonical `state`.
157    /// `license_id` must match the id used when the state was created.
158    pub fn repair(&self, state: &LicenseState, license_id: &str) -> usize {
159        let mut repaired = 0;
160        let key = &self.state_integrity_key;
161
162        for path in &self.paths {
163            let needs_repair =
164                !path.exists() || !matches!(LicenseState::load(path, license_id, key), Ok(Some(_)));
165
166            if needs_repair {
167                if let Some(parent) = path.parent() {
168                    let _ = std::fs::create_dir_all(parent);
169                }
170                if state.save(path, key).is_ok() {
171                    repaired += 1;
172                    tracing::info!("Repaired state file {:?}", path);
173                }
174            }
175        }
176
177        repaired
178    }
179
180    pub fn save(&self, state: &LicenseState) -> Result<()> {
181        let mut success_count = 0;
182        let mut errors = Vec::new();
183        let key = &self.state_integrity_key;
184
185        for path in &self.paths {
186            if let Some(parent) = path.parent() {
187                let _ = std::fs::create_dir_all(parent);
188            }
189
190            match state.save(path, key) {
191                Ok(_) => success_count += 1,
192                Err(e) => errors.push((path.clone(), e)),
193            }
194        }
195
196        if success_count == 0 {
197            return Err(LicenseError::StateFileTampered);
198        }
199
200        for (path, error) in errors {
201            tracing::warn!("Failed to save state to {:?}: {}", path, error);
202        }
203
204        Ok(())
205    }
206
207    pub fn clear(&self) -> Result<()> {
208        for path in &self.paths {
209            let _ = std::fs::remove_file(path);
210        }
211        Ok(())
212    }
213
214    pub fn paths(&self) -> &[PathBuf] {
215        &self.paths
216    }
217}
218
219fn sha256_short(input: &str) -> String {
220    let mut hasher = Sha256::new();
221    hasher.update(input.as_bytes());
222    let result = hasher.finalize();
223    hex::encode(&result[..16])
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use tempfile::TempDir;
230
231    const TEST_KEY: [u8; 32] = [42u8; 32];
232
233    #[test]
234    fn test_state_manager_round_trip() {
235        let temp_dir = TempDir::new().unwrap();
236        let paths = vec![
237            temp_dir.path().join("state1.dat"),
238            temp_dir.path().join("state2.dat"),
239        ];
240
241        let manager = StateManager::with_paths("test-license", paths, TEST_KEY);
242
243        let loaded = manager.load("test-license").unwrap();
244        assert!(loaded.is_none());
245
246        let state = LicenseState::new("test-license");
247        manager.save(&state).unwrap();
248
249        let loaded = manager.load("test-license").unwrap();
250        assert!(loaded.is_some());
251        assert_eq!(loaded.unwrap().validation_count, 1);
252    }
253
254    #[test]
255    fn test_state_manager_detects_missing() {
256        let temp_dir = TempDir::new().unwrap();
257        let paths = vec![
258            temp_dir.path().join("state1.dat"),
259            temp_dir.path().join("state2.dat"),
260            temp_dir.path().join("state3.dat"),
261        ];
262
263        let manager = StateManager::with_paths("test-license", paths.clone(), TEST_KEY);
264
265        let mut state = LicenseState::new("test-license");
266        state.validation_count = 5;
267        manager.save(&state).unwrap();
268
269        for path in &paths {
270            assert!(path.exists(), "File should exist: {:?}", path);
271        }
272
273        std::fs::remove_file(&paths[1]).unwrap();
274        assert!(!paths[1].exists());
275
276        let (loaded, observations) = manager.load_with_observations("test-license").unwrap();
277        assert!(loaded.is_some());
278        assert!(observations.has_inconsistency());
279        assert_eq!(observations.missing_locations.len(), 1);
280        assert_eq!(observations.valid_locations.len(), 2);
281
282        assert!(!paths[1].exists());
283
284        let repaired = manager.repair(&state, "test-license");
285        assert!(repaired >= 1);
286
287        for path in &paths {
288            assert!(path.exists(), "File should be restored: {:?}", path);
289        }
290    }
291
292    #[test]
293    fn test_state_manager_uses_newest() {
294        let temp_dir = TempDir::new().unwrap();
295        let paths = vec![
296            temp_dir.path().join("state1.dat"),
297            temp_dir.path().join("state2.dat"),
298        ];
299
300        let mut state1 = LicenseState::new("test-license");
301        state1.validation_count = 10;
302        state1.save(&paths[0], &TEST_KEY).unwrap();
303
304        let mut state2 = LicenseState::new("test-license");
305        state2.validation_count = 20;
306        state2.save(&paths[1], &TEST_KEY).unwrap();
307
308        let manager = StateManager::with_paths("test-license", paths, TEST_KEY);
309        let loaded = manager.load("test-license").unwrap().unwrap();
310
311        assert_eq!(loaded.validation_count, 20);
312    }
313}