Skip to main content

dotm/
state.rs

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 = 3;
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    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub staged: Option<PathBuf>,
78    pub source: PathBuf,
79    pub content_hash: String,
80    #[serde(default)]
81    pub original_hash: Option<String>,
82    pub kind: EntryKind,
83    pub package: String,
84    #[serde(default)]
85    pub owner: Option<String>,
86    #[serde(default)]
87    pub group: Option<String>,
88    #[serde(default)]
89    pub mode: Option<String>,
90    #[serde(default)]
91    pub original_owner: Option<String>,
92    #[serde(default)]
93    pub original_group: Option<String>,
94    #[serde(default)]
95    pub original_mode: Option<String>,
96}
97
98impl DeployState {
99    pub fn new(state_dir: &Path) -> Self {
100        Self {
101            version: CURRENT_VERSION,
102            state_dir: state_dir.to_path_buf(),
103            ..Default::default()
104        }
105    }
106
107    /// Acquire an exclusive file lock without loading existing state.
108    pub fn lock(&mut self) -> Result<()> {
109        std::fs::create_dir_all(&self.state_dir).with_context(|| {
110            format!(
111                "failed to create state directory: {}",
112                self.state_dir.display()
113            )
114        })?;
115        let lock_path = self.state_dir.join("dotm.lock");
116        let lock_file = std::fs::OpenOptions::new()
117            .create(true)
118            .write(true)
119            .truncate(false)
120            .open(&lock_path)
121            .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
122
123        lock_file.try_lock_exclusive().map_err(|_| {
124            anyhow::anyhow!(
125                "another dotm process is running (could not acquire lock on {})",
126                lock_path.display()
127            )
128        })?;
129
130        self.lock = Some(lock_file);
131        Ok(())
132    }
133
134    pub fn load(state_dir: &Path) -> Result<Self> {
135        // Only run v1->v2 storage migration for legacy state dirs (not ~/.dotm/)
136        let is_legacy = state_dir.file_name().map(|n| n != ".dotm").unwrap_or(true);
137        if is_legacy {
138            Self::migrate_storage(state_dir)?;
139        }
140        let path = state_dir.join(STATE_FILE);
141        if !path.exists() {
142            return Ok(Self::new(state_dir));
143        }
144        let content = std::fs::read_to_string(&path)
145            .with_context(|| format!("failed to read state file: {}", path.display()))?;
146        let mut state: DeployState = serde_json::from_str(&content)
147            .with_context(|| format!("failed to parse state file: {}", path.display()))?;
148        if state.version > CURRENT_VERSION {
149            anyhow::bail!(
150                "state file was created by a newer version of dotm (state version {}, max supported {})",
151                state.version,
152                CURRENT_VERSION
153            );
154        }
155        if state.version < CURRENT_VERSION {
156            state.version = CURRENT_VERSION;
157        }
158        state.state_dir = state_dir.to_path_buf();
159        Ok(state)
160    }
161
162    /// Load state with an exclusive file lock to prevent concurrent access.
163    /// The lock is held until the DeployState is dropped.
164    pub fn load_locked(state_dir: &Path) -> Result<Self> {
165        std::fs::create_dir_all(state_dir).with_context(|| {
166            format!("failed to create state directory: {}", state_dir.display())
167        })?;
168        let lock_path = state_dir.join("dotm.lock");
169        let lock_file = std::fs::OpenOptions::new()
170            .create(true)
171            .write(true)
172            .truncate(false)
173            .open(&lock_path)
174            .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
175
176        lock_file.try_lock_exclusive().map_err(|_| {
177            anyhow::anyhow!(
178                "another dotm process is running (could not acquire lock on {})",
179                lock_path.display()
180            )
181        })?;
182
183        let mut state = Self::load(state_dir)?;
184        state.lock = Some(lock_file);
185        Ok(state)
186    }
187
188    pub fn save(&self) -> Result<()> {
189        std::fs::create_dir_all(&self.state_dir).with_context(|| {
190            format!(
191                "failed to create state directory: {}",
192                self.state_dir.display()
193            )
194        })?;
195        let path = self.state_dir.join(STATE_FILE);
196        let tmp_path = self.state_dir.join(".dotm-state.json.tmp");
197        let content = serde_json::to_string_pretty(self)?;
198        std::fs::write(&tmp_path, &content)
199            .with_context(|| format!("failed to write temp state file: {}", tmp_path.display()))?;
200        std::fs::rename(&tmp_path, &path).with_context(|| {
201            format!(
202                "failed to rename temp state file: {} -> {}",
203                tmp_path.display(),
204                path.display()
205            )
206        })?;
207        Ok(())
208    }
209
210    pub fn record(&mut self, entry: DeployEntry) {
211        self.entries.push(entry);
212    }
213
214    pub fn entries(&self) -> &[DeployEntry] {
215        &self.entries
216    }
217
218    pub fn remove_targets(&mut self, targets: &[PathBuf]) {
219        self.entries.retain(|e| !targets.contains(&e.target));
220    }
221
222    pub fn entries_mut(&mut self) -> &mut [DeployEntry] {
223        &mut self.entries
224    }
225
226    pub fn update_entry_hash(&mut self, index: usize, new_hash: String) {
227        if let Some(entry) = self.entries.get_mut(index) {
228            entry.content_hash = new_hash;
229        }
230    }
231
232    pub fn check_entry_status(&self, entry: &DeployEntry) -> FileStatus {
233        if !entry.target.exists() && !entry.target.is_symlink() {
234            return FileStatus::missing();
235        }
236
237        let mut status = FileStatus::ok();
238
239        if entry.target.is_symlink() {
240            // Symlink path: check that it points to the expected source
241            let link_dest = match std::fs::read_link(&entry.target) {
242                Ok(dest) => dest,
243                Err(_) => return FileStatus::missing(),
244            };
245
246            let canon_link = std::fs::canonicalize(&link_dest)
247                .or_else(|_| std::fs::canonicalize(&entry.target))
248                .unwrap_or(link_dest.clone());
249            let canon_source =
250                std::fs::canonicalize(&entry.source).unwrap_or_else(|_| entry.source.clone());
251
252            if canon_link == canon_source {
253                // Direct symlink to source — no content drift possible
254            } else if let Some(ref staged) = entry.staged {
255                // Transitional v2 compatibility: symlink points to staged path
256                let canon_staged = std::fs::canonicalize(staged).unwrap_or_else(|_| staged.clone());
257                if canon_link == canon_staged {
258                    // v2 staged symlink — check hash for drift
259                    if let Ok(current_hash) = hash::hash_file(staged)
260                        && current_hash != entry.content_hash
261                    {
262                        status.content_modified = true;
263                    }
264                } else {
265                    return FileStatus::missing();
266                }
267            } else {
268                return FileStatus::missing();
269            }
270        } else {
271            // Regular file (copy/template): hash the target
272            if let Ok(current_hash) = hash::hash_file(&entry.target)
273                && current_hash != entry.content_hash
274            {
275                status.content_modified = true;
276            }
277        }
278
279        // Metadata checks (only if we recorded what we set)
280        if let Ok((current_owner, current_group, current_mode)) =
281            crate::metadata::read_file_metadata(&entry.target)
282        {
283            if let Some(ref expected_owner) = entry.owner {
284                if current_owner != *expected_owner {
285                    status.owner_changed = true;
286                }
287            }
288            if let Some(ref expected_group) = entry.group {
289                if current_group != *expected_group {
290                    status.group_changed = true;
291                }
292            }
293            if let Some(ref expected_mode) = entry.mode {
294                if current_mode != *expected_mode {
295                    status.mode_changed = true;
296                }
297            }
298        }
299
300        status
301    }
302
303    pub fn originals_dir(&self) -> PathBuf {
304        self.state_dir.join("originals")
305    }
306
307    pub fn store_original(&self, content_hash: &str, content: &[u8]) -> Result<()> {
308        let dir = self.originals_dir();
309        std::fs::create_dir_all(&dir)
310            .with_context(|| format!("failed to create originals directory: {}", dir.display()))?;
311        let path = dir.join(content_hash);
312        if !path.exists() {
313            std::fs::write(&path, content)
314                .with_context(|| format!("failed to store original: {}", path.display()))?;
315        }
316        Ok(())
317    }
318
319    pub fn load_original(&self, content_hash: &str) -> Result<Vec<u8>> {
320        let path = self.originals_dir().join(content_hash);
321        std::fs::read(&path)
322            .with_context(|| format!("failed to load original content: {}", path.display()))
323    }
324
325    pub fn migrate_storage(state_dir: &Path) -> Result<()> {
326        let originals = state_dir.join("originals");
327        let deployed = state_dir.join("deployed");
328        if originals.is_dir() && !deployed.exists() {
329            std::fs::rename(&originals, &deployed)
330                .with_context(|| "failed to migrate originals/ to deployed/")?;
331        }
332        Ok(())
333    }
334
335    /// Restore files to their pre-dotm state.
336    /// Files with original_hash get their original content written back with original metadata.
337    /// Files without original_hash (dotm created them) get removed.
338    /// Returns the count of restored files.
339    pub fn restore(&mut self, package_filter: Option<&str>) -> Result<usize> {
340        let mut restored = 0;
341        let mut remaining = Vec::new();
342        let mut restore_error: Option<anyhow::Error> = None;
343
344        for entry in &self.entries {
345            if let Some(filter) = package_filter {
346                if entry.package != filter {
347                    remaining.push(entry.clone());
348                    continue;
349                }
350            }
351
352            // On error, keep unprocessed entries in remaining so state stays consistent
353            if restore_error.is_some() {
354                remaining.push(entry.clone());
355                continue;
356            }
357
358            let result: Result<()> = (|| {
359                if let Some(ref orig_hash) = entry.original_hash {
360                    let original_content = self.load_original(orig_hash)?;
361                    std::fs::write(&entry.target, &original_content).with_context(|| {
362                        format!("failed to restore: {}", entry.target.display())
363                    })?;
364
365                    if entry.original_owner.is_some() || entry.original_group.is_some() {
366                        let _ = crate::metadata::apply_ownership(
367                            &entry.target,
368                            entry.original_owner.as_deref(),
369                            entry.original_group.as_deref(),
370                        );
371                    }
372                    if let Some(ref orig_mode) = entry.original_mode {
373                        let _ =
374                            crate::deployer::apply_permission_override(&entry.target, orig_mode);
375                    }
376                } else if entry.target.exists() || entry.target.is_symlink() {
377                    std::fs::remove_file(&entry.target)
378                        .with_context(|| format!("failed to remove: {}", entry.target.display()))?;
379                    cleanup_empty_parents(&entry.target);
380                }
381                Ok(())
382            })();
383
384            match result {
385                Ok(()) => restored += 1,
386                Err(e) => {
387                    remaining.push(entry.clone());
388                    restore_error = Some(e);
389                }
390            }
391        }
392
393        if package_filter.is_some() {
394            self.entries = remaining;
395            self.save()?;
396        } else if restore_error.is_none() {
397            let originals = self.originals_dir();
398            if originals.is_dir() {
399                let _ = std::fs::remove_dir_all(&originals);
400            }
401            let state_path = self.state_dir.join(STATE_FILE);
402            if state_path.exists() {
403                std::fs::remove_file(&state_path)?;
404            }
405        }
406
407        if let Some(e) = restore_error {
408            return Err(e);
409        }
410
411        Ok(restored)
412    }
413
414    /// Remove managed files for a single package and save updated state.
415    pub fn undeploy_package(&mut self, package: &str) -> Result<usize> {
416        let mut removed = 0;
417        let mut remaining = Vec::new();
418
419        for entry in &self.entries {
420            if entry.package == package {
421                if entry.target.is_symlink() || entry.target.exists() {
422                    std::fs::remove_file(&entry.target).with_context(|| {
423                        format!("failed to remove target: {}", entry.target.display())
424                    })?;
425                    cleanup_empty_parents(&entry.target);
426                    removed += 1;
427                }
428            } else {
429                remaining.push(entry.clone());
430            }
431        }
432
433        self.entries = remaining;
434        self.save()?;
435
436        Ok(removed)
437    }
438
439    /// Remove all managed files and return a count of removed files.
440    pub fn undeploy(&self) -> Result<usize> {
441        let mut removed = 0;
442
443        for entry in &self.entries {
444            if entry.target.is_symlink() || entry.target.exists() {
445                std::fs::remove_file(&entry.target).with_context(|| {
446                    format!("failed to remove target: {}", entry.target.display())
447                })?;
448                cleanup_empty_parents(&entry.target);
449                removed += 1;
450            }
451        }
452
453        // Clean up originals directory
454        let originals = self.originals_dir();
455        if originals.is_dir() {
456            let _ = std::fs::remove_dir_all(&originals);
457        }
458
459        // Remove the state file itself
460        let state_path = self.state_dir.join(STATE_FILE);
461        if state_path.exists() {
462            std::fs::remove_file(&state_path)?;
463        }
464
465        Ok(removed)
466    }
467}
468
469pub fn cleanup_empty_parents(path: &Path) {
470    let mut current = path.parent();
471    while let Some(parent) = current {
472        if parent == Path::new("") || parent == Path::new("/") {
473            break;
474        }
475        match std::fs::read_dir(parent) {
476            Ok(mut entries) => {
477                if entries.next().is_none() {
478                    let _ = std::fs::remove_dir(parent);
479                    current = parent.parent();
480                } else {
481                    break;
482                }
483            }
484            Err(_) => break,
485        }
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use tempfile::TempDir;
493
494    #[test]
495    fn store_and_load_original_content() {
496        let dir = TempDir::new().unwrap();
497        let state = DeployState::new(dir.path());
498        state
499            .store_original("orig456", b"original pre-existing content")
500            .unwrap();
501        let loaded = state.load_original("orig456").unwrap();
502        assert_eq!(loaded, b"original pre-existing content");
503    }
504
505    #[test]
506    fn migrate_renames_originals_to_deployed() {
507        let dir = TempDir::new().unwrap();
508        let originals = dir.path().join("originals");
509        std::fs::create_dir_all(&originals).unwrap();
510        std::fs::write(originals.join("hash1"), "content1").unwrap();
511
512        DeployState::migrate_storage(dir.path()).unwrap();
513
514        assert!(!originals.exists());
515        let deployed = dir.path().join("deployed");
516        assert!(deployed.exists());
517        assert_eq!(
518            std::fs::read_to_string(deployed.join("hash1")).unwrap(),
519            "content1"
520        );
521    }
522
523    #[test]
524    fn migrate_noop_if_deployed_exists() {
525        let dir = TempDir::new().unwrap();
526        let deployed = dir.path().join("deployed");
527        std::fs::create_dir_all(&deployed).unwrap();
528        std::fs::write(deployed.join("hash1"), "existing").unwrap();
529
530        let originals = dir.path().join("originals");
531        std::fs::create_dir_all(&originals).unwrap();
532        std::fs::write(originals.join("hash1"), "should not replace").unwrap();
533
534        DeployState::migrate_storage(dir.path()).unwrap();
535
536        assert_eq!(
537            std::fs::read_to_string(deployed.join("hash1")).unwrap(),
538            "existing"
539        );
540    }
541
542    #[test]
543    fn concurrent_lock_fails() {
544        use fs2::FileExt;
545        let dir = TempDir::new().unwrap();
546        std::fs::create_dir_all(dir.path()).unwrap();
547        let lock_path = dir.path().join("dotm.lock");
548        std::fs::write(&lock_path, "").unwrap();
549
550        // Hold the lock
551        let f = std::fs::File::open(&lock_path).unwrap();
552        f.lock_exclusive().unwrap();
553
554        // Try to acquire from DeployState — should fail
555        let result = DeployState::load_locked(dir.path());
556        assert!(result.is_err());
557        assert!(
558            result
559                .unwrap_err()
560                .to_string()
561                .contains("another dotm process")
562        );
563    }
564}