licenz_core/
state_manager.rs1use crate::anti_tamper::LicenseState;
13use crate::error::{LicenseError, Result};
14use sha2::{Digest, Sha256};
15use std::path::PathBuf;
16
17#[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
43pub struct StateManager {
45 paths: Vec<PathBuf>,
46 state_integrity_key: [u8; 32],
47}
48
49impl StateManager {
50 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 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 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}