1use crate::hash;
2use crate::scanner::EntryKind;
3use anyhow::{Context, Result};
4use fs2::FileExt;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
9pub struct FileStatus {
10 pub exists: bool,
11 pub content_modified: bool,
12 pub owner_changed: bool,
13 pub group_changed: bool,
14 pub mode_changed: bool,
15}
16
17impl FileStatus {
18 pub fn ok() -> Self {
19 Self {
20 exists: true,
21 content_modified: false,
22 owner_changed: false,
23 group_changed: false,
24 mode_changed: false,
25 }
26 }
27
28 pub fn missing() -> Self {
29 Self {
30 exists: false,
31 content_modified: false,
32 owner_changed: false,
33 group_changed: false,
34 mode_changed: false,
35 }
36 }
37
38 pub fn is_ok(&self) -> bool {
39 self.exists
40 && !self.content_modified
41 && !self.owner_changed
42 && !self.group_changed
43 && !self.mode_changed
44 }
45
46 pub fn is_missing(&self) -> bool {
47 !self.exists
48 }
49
50 pub fn is_modified(&self) -> bool {
51 self.content_modified
52 }
53
54 pub fn has_metadata_drift(&self) -> bool {
55 self.owner_changed || self.group_changed || self.mode_changed
56 }
57}
58
59const STATE_FILE: &str = "dotm-state.json";
60const CURRENT_VERSION: u32 = 2;
61
62#[derive(Debug, Default, Serialize, Deserialize)]
63pub struct DeployState {
64 #[serde(default)]
65 version: u32,
66 #[serde(skip)]
67 state_dir: PathBuf,
68 #[serde(skip)]
69 lock: Option<std::fs::File>,
70 entries: Vec<DeployEntry>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DeployEntry {
75 pub target: PathBuf,
76 pub staged: PathBuf,
77 pub source: PathBuf,
78 pub content_hash: String,
79 #[serde(default)]
80 pub original_hash: Option<String>,
81 pub kind: EntryKind,
82 pub package: String,
83 #[serde(default)]
84 pub owner: Option<String>,
85 #[serde(default)]
86 pub group: Option<String>,
87 #[serde(default)]
88 pub mode: Option<String>,
89 #[serde(default)]
90 pub original_owner: Option<String>,
91 #[serde(default)]
92 pub original_group: Option<String>,
93 #[serde(default)]
94 pub original_mode: Option<String>,
95}
96
97impl DeployState {
98 pub fn new(state_dir: &Path) -> Self {
99 Self {
100 version: CURRENT_VERSION,
101 state_dir: state_dir.to_path_buf(),
102 ..Default::default()
103 }
104 }
105
106 pub fn load(state_dir: &Path) -> Result<Self> {
107 Self::migrate_storage(state_dir)?;
108 let path = state_dir.join(STATE_FILE);
109 if !path.exists() {
110 return Ok(Self::new(state_dir));
111 }
112 let content = std::fs::read_to_string(&path)
113 .with_context(|| format!("failed to read state file: {}", path.display()))?;
114 let mut state: DeployState = serde_json::from_str(&content)
115 .with_context(|| format!("failed to parse state file: {}", path.display()))?;
116 if state.version > CURRENT_VERSION {
117 anyhow::bail!(
118 "state file was created by a newer version of dotm (state version {}, max supported {})",
119 state.version, CURRENT_VERSION
120 );
121 }
122 if state.version < CURRENT_VERSION {
123 state.version = CURRENT_VERSION;
124 }
125 state.state_dir = state_dir.to_path_buf();
126 Ok(state)
127 }
128
129 pub fn load_locked(state_dir: &Path) -> Result<Self> {
132 std::fs::create_dir_all(state_dir)
133 .with_context(|| format!("failed to create state directory: {}", state_dir.display()))?;
134 let lock_path = state_dir.join("dotm.lock");
135 let lock_file = std::fs::OpenOptions::new()
136 .create(true)
137 .write(true)
138 .truncate(false)
139 .open(&lock_path)
140 .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
141
142 lock_file.try_lock_exclusive().map_err(|_| {
143 anyhow::anyhow!(
144 "another dotm process is running (could not acquire lock on {})",
145 lock_path.display()
146 )
147 })?;
148
149 let mut state = Self::load(state_dir)?;
150 state.lock = Some(lock_file);
151 Ok(state)
152 }
153
154 pub fn save(&self) -> Result<()> {
155 std::fs::create_dir_all(&self.state_dir)
156 .with_context(|| format!("failed to create state directory: {}", self.state_dir.display()))?;
157 let path = self.state_dir.join(STATE_FILE);
158 let content = serde_json::to_string_pretty(self)?;
159 std::fs::write(&path, content)
160 .with_context(|| format!("failed to write state file: {}", path.display()))?;
161 Ok(())
162 }
163
164 pub fn record(&mut self, entry: DeployEntry) {
165 self.entries.push(entry);
166 }
167
168 pub fn entries(&self) -> &[DeployEntry] {
169 &self.entries
170 }
171
172 pub fn entries_mut(&mut self) -> &mut [DeployEntry] {
173 &mut self.entries
174 }
175
176 pub fn update_entry_hash(&mut self, index: usize, new_hash: String) {
177 if let Some(entry) = self.entries.get_mut(index) {
178 entry.content_hash = new_hash;
179 }
180 }
181
182 pub fn check_entry_status(&self, entry: &DeployEntry) -> FileStatus {
183 if !entry.target.exists() && !entry.target.is_symlink() {
184 return FileStatus::missing();
185 }
186
187 let mut status = FileStatus::ok();
188
189 if entry.staged.exists() {
190 if let Ok(current_hash) = hash::hash_file(&entry.staged)
191 && current_hash != entry.content_hash
192 {
193 status.content_modified = true;
194 }
195 } else {
196 return FileStatus::missing();
197 }
198
199 if let Ok((current_owner, current_group, current_mode)) =
201 crate::metadata::read_file_metadata(&entry.target)
202 {
203 if let Some(ref expected_owner) = entry.owner {
204 if current_owner != *expected_owner {
205 status.owner_changed = true;
206 }
207 }
208 if let Some(ref expected_group) = entry.group {
209 if current_group != *expected_group {
210 status.group_changed = true;
211 }
212 }
213 if let Some(ref expected_mode) = entry.mode {
214 if current_mode != *expected_mode {
215 status.mode_changed = true;
216 }
217 }
218 }
219
220 status
221 }
222
223 pub fn originals_dir(&self) -> PathBuf {
224 self.state_dir.join("originals")
225 }
226
227 pub fn store_original(&self, content_hash: &str, content: &[u8]) -> Result<()> {
228 let dir = self.originals_dir();
229 std::fs::create_dir_all(&dir)
230 .with_context(|| format!("failed to create originals directory: {}", dir.display()))?;
231 let path = dir.join(content_hash);
232 if !path.exists() {
233 std::fs::write(&path, content)
234 .with_context(|| format!("failed to store original: {}", path.display()))?;
235 }
236 Ok(())
237 }
238
239 pub fn load_original(&self, content_hash: &str) -> Result<Vec<u8>> {
240 let path = self.originals_dir().join(content_hash);
241 std::fs::read(&path)
242 .with_context(|| format!("failed to load original content: {}", path.display()))
243 }
244
245 pub fn deployed_dir(&self) -> PathBuf {
246 self.state_dir.join("deployed")
247 }
248
249 pub fn store_deployed(&self, content_hash: &str, content: &[u8]) -> Result<()> {
250 let dir = self.deployed_dir();
251 std::fs::create_dir_all(&dir)
252 .with_context(|| format!("failed to create deployed directory: {}", dir.display()))?;
253 let path = dir.join(content_hash);
254 if !path.exists() {
255 std::fs::write(&path, content)
256 .with_context(|| format!("failed to store deployed content: {}", path.display()))?;
257 }
258 Ok(())
259 }
260
261 pub fn load_deployed(&self, content_hash: &str) -> Result<Vec<u8>> {
262 let path = self.deployed_dir().join(content_hash);
263 std::fs::read(&path)
264 .with_context(|| format!("failed to load deployed content: {}", path.display()))
265 }
266
267 pub fn migrate_storage(state_dir: &Path) -> Result<()> {
268 let originals = state_dir.join("originals");
269 let deployed = state_dir.join("deployed");
270 if originals.is_dir() && !deployed.exists() {
271 std::fs::rename(&originals, &deployed)
272 .with_context(|| "failed to migrate originals/ to deployed/")?;
273 }
274 Ok(())
275 }
276
277 pub fn restore(&self, package_filter: Option<&str>) -> Result<usize> {
282 let mut restored = 0;
283
284 for entry in &self.entries {
285 if let Some(filter) = package_filter {
286 if entry.package != filter {
287 continue;
288 }
289 }
290
291 if let Some(ref orig_hash) = entry.original_hash {
292 let original_content = self.load_original(orig_hash)?;
294 std::fs::write(&entry.target, &original_content)
295 .with_context(|| format!("failed to restore: {}", entry.target.display()))?;
296
297 if entry.original_owner.is_some() || entry.original_group.is_some() {
299 let _ = crate::metadata::apply_ownership(
300 &entry.target,
301 entry.original_owner.as_deref(),
302 entry.original_group.as_deref(),
303 );
304 }
305 if let Some(ref orig_mode) = entry.original_mode {
306 let _ = crate::deployer::apply_permission_override(&entry.target, orig_mode);
307 }
308
309 restored += 1;
310 } else {
311 if entry.target.exists() || entry.target.is_symlink() {
313 std::fs::remove_file(&entry.target)
314 .with_context(|| format!("failed to remove: {}", entry.target.display()))?;
315 cleanup_empty_parents(&entry.target);
316 restored += 1;
317 }
318 }
319
320 if entry.staged != entry.target && entry.staged.exists() {
322 std::fs::remove_file(&entry.staged)
323 .with_context(|| format!("failed to remove staged: {}", entry.staged.display()))?;
324 cleanup_empty_parents(&entry.staged);
325 }
326 }
327
328 if package_filter.is_none() {
330 let deployed = self.deployed_dir();
331 if deployed.is_dir() {
332 let _ = std::fs::remove_dir_all(&deployed);
333 }
334 let originals = self.originals_dir();
335 if originals.is_dir() {
336 let _ = std::fs::remove_dir_all(&originals);
337 }
338 let state_path = self.state_dir.join(STATE_FILE);
339 if state_path.exists() {
340 std::fs::remove_file(&state_path)?;
341 }
342 }
343
344 Ok(restored)
345 }
346
347 pub fn undeploy_package(&mut self, package: &str) -> Result<usize> {
349 let mut removed = 0;
350 let mut remaining = Vec::new();
351
352 for entry in &self.entries {
353 if entry.package == package {
354 if entry.target.is_symlink() || entry.target.exists() {
355 std::fs::remove_file(&entry.target)
356 .with_context(|| format!("failed to remove target: {}", entry.target.display()))?;
357 cleanup_empty_parents(&entry.target);
358 removed += 1;
359 }
360
361 if entry.staged != entry.target && entry.staged.exists() {
362 std::fs::remove_file(&entry.staged)
363 .with_context(|| format!("failed to remove staged file: {}", entry.staged.display()))?;
364 cleanup_empty_parents(&entry.staged);
365 }
366 } else {
367 remaining.push(entry.clone());
368 }
369 }
370
371 self.entries = remaining;
372 self.save()?;
373
374 Ok(removed)
375 }
376
377 pub fn undeploy(&self) -> Result<usize> {
379 let mut removed = 0;
380
381 for entry in &self.entries {
382 if entry.target.is_symlink() || entry.target.exists() {
383 std::fs::remove_file(&entry.target)
384 .with_context(|| format!("failed to remove target: {}", entry.target.display()))?;
385 cleanup_empty_parents(&entry.target);
386 removed += 1;
387 }
388
389 if entry.staged.exists() {
390 std::fs::remove_file(&entry.staged)
391 .with_context(|| format!("failed to remove staged file: {}", entry.staged.display()))?;
392 cleanup_empty_parents(&entry.staged);
393 }
394 }
395
396 let originals = self.originals_dir();
398 if originals.is_dir() {
399 let _ = std::fs::remove_dir_all(&originals);
400 }
401
402 let deployed = self.deployed_dir();
404 if deployed.is_dir() {
405 let _ = std::fs::remove_dir_all(&deployed);
406 }
407
408 let state_path = self.state_dir.join(STATE_FILE);
410 if state_path.exists() {
411 std::fs::remove_file(&state_path)?;
412 }
413
414 Ok(removed)
415 }
416}
417
418pub fn cleanup_empty_parents(path: &Path) {
419 let mut current = path.parent();
420 while let Some(parent) = current {
421 if parent == Path::new("") || parent == Path::new("/") {
422 break;
423 }
424 match std::fs::read_dir(parent) {
425 Ok(mut entries) => {
426 if entries.next().is_none() {
427 let _ = std::fs::remove_dir(parent);
428 current = parent.parent();
429 } else {
430 break;
431 }
432 }
433 Err(_) => break,
434 }
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use tempfile::TempDir;
442
443 #[test]
444 fn deployed_dir_is_separate_from_originals() {
445 let dir = TempDir::new().unwrap();
446 let state = DeployState::new(dir.path());
447 assert_ne!(state.originals_dir(), state.deployed_dir());
448 assert!(state.originals_dir().ends_with("originals"));
449 assert!(state.deployed_dir().ends_with("deployed"));
450 }
451
452 #[test]
453 fn store_and_load_deployed_content() {
454 let dir = TempDir::new().unwrap();
455 let state = DeployState::new(dir.path());
456 state.store_deployed("abc123", b"deployed file content").unwrap();
457 let loaded = state.load_deployed("abc123").unwrap();
458 assert_eq!(loaded, b"deployed file content");
459 }
460
461 #[test]
462 fn store_and_load_original_content() {
463 let dir = TempDir::new().unwrap();
464 let state = DeployState::new(dir.path());
465 state.store_original("orig456", b"original pre-existing content").unwrap();
466 let loaded = state.load_original("orig456").unwrap();
467 assert_eq!(loaded, b"original pre-existing content");
468 }
469
470 #[test]
471 fn migrate_renames_originals_to_deployed() {
472 let dir = TempDir::new().unwrap();
473 let originals = dir.path().join("originals");
474 std::fs::create_dir_all(&originals).unwrap();
475 std::fs::write(originals.join("hash1"), "content1").unwrap();
476
477 DeployState::migrate_storage(dir.path()).unwrap();
478
479 assert!(!originals.exists());
480 let deployed = dir.path().join("deployed");
481 assert!(deployed.exists());
482 assert_eq!(std::fs::read_to_string(deployed.join("hash1")).unwrap(), "content1");
483 }
484
485 #[test]
486 fn migrate_noop_if_deployed_exists() {
487 let dir = TempDir::new().unwrap();
488 let deployed = dir.path().join("deployed");
489 std::fs::create_dir_all(&deployed).unwrap();
490 std::fs::write(deployed.join("hash1"), "existing").unwrap();
491
492 let originals = dir.path().join("originals");
493 std::fs::create_dir_all(&originals).unwrap();
494 std::fs::write(originals.join("hash1"), "should not replace").unwrap();
495
496 DeployState::migrate_storage(dir.path()).unwrap();
497
498 assert_eq!(std::fs::read_to_string(deployed.join("hash1")).unwrap(), "existing");
499 }
500
501 #[test]
502 fn concurrent_lock_fails() {
503 use fs2::FileExt;
504 let dir = TempDir::new().unwrap();
505 std::fs::create_dir_all(dir.path()).unwrap();
506 let lock_path = dir.path().join("dotm.lock");
507 std::fs::write(&lock_path, "").unwrap();
508
509 let f = std::fs::File::open(&lock_path).unwrap();
511 f.lock_exclusive().unwrap();
512
513 let result = DeployState::load_locked(dir.path());
515 assert!(result.is_err());
516 assert!(result.unwrap_err().to_string().contains("another dotm process"));
517 }
518}