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