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 = 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    /// Load state with an exclusive file lock to prevent concurrent access.
130    /// The lock is held until the DeployState is dropped.
131    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        // Metadata checks (only if we recorded what we set)
200        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    /// Restore files to their pre-dotm state.
278    /// Files with original_hash get their original content written back with original metadata.
279    /// Files without original_hash (dotm created them) get removed.
280    /// Returns the count of restored files.
281    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                // Restore original content
293                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                // Restore original metadata if recorded
298                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                // No original — file was created by dotm, remove it
312                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            // Clean up staged file if separate from target
321            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        // Clean up state directories if restoring everything (no package filter)
329        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    /// Remove managed files for a single package and save updated state.
348    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    /// Remove all managed files and return a count of removed files.
378    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        // Clean up originals directory
397        let originals = self.originals_dir();
398        if originals.is_dir() {
399            let _ = std::fs::remove_dir_all(&originals);
400        }
401
402        // Clean up deployed directory
403        let deployed = self.deployed_dir();
404        if deployed.is_dir() {
405            let _ = std::fs::remove_dir_all(&deployed);
406        }
407
408        // Remove the state file itself
409        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        // Hold the lock
510        let f = std::fs::File::open(&lock_path).unwrap();
511        f.lock_exclusive().unwrap();
512
513        // Try to acquire from DeployState — should fail
514        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}