1use crate::hash;
2use crate::scanner::EntryKind;
3use anyhow::{Context, Result};
4use fs2::FileExt;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct FileStatus {
11 pub exists: bool,
12 pub content_modified: bool,
13 pub owner_changed: bool,
14 pub group_changed: bool,
15 pub mode_changed: bool,
16}
17
18impl FileStatus {
19 pub fn ok() -> Self {
20 Self {
21 exists: true,
22 content_modified: false,
23 owner_changed: false,
24 group_changed: false,
25 mode_changed: false,
26 }
27 }
28
29 pub fn missing() -> Self {
30 Self {
31 exists: false,
32 content_modified: false,
33 owner_changed: false,
34 group_changed: false,
35 mode_changed: false,
36 }
37 }
38
39 pub fn is_ok(&self) -> bool {
40 self.exists
41 && !self.content_modified
42 && !self.owner_changed
43 && !self.group_changed
44 && !self.mode_changed
45 }
46
47 pub fn is_missing(&self) -> bool {
48 !self.exists
49 }
50
51 pub fn is_modified(&self) -> bool {
52 self.content_modified
53 }
54
55 pub fn has_metadata_drift(&self) -> bool {
56 self.owner_changed || self.group_changed || self.mode_changed
57 }
58}
59
60const STATE_FILE: &str = "dotm-state.json";
61const CURRENT_VERSION: u32 = 3;
62
63#[derive(Debug, Default, Serialize, Deserialize)]
64pub struct DeployState {
65 #[serde(default)]
66 version: u32,
67 #[serde(skip)]
68 state_dir: PathBuf,
69 #[serde(skip)]
70 lock: Option<std::fs::File>,
71 entries: Vec<DeployEntry>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DeployEntry {
76 pub target: PathBuf,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub staged: Option<PathBuf>,
79 pub source: PathBuf,
80 pub content_hash: String,
81 #[serde(default)]
82 pub original_hash: Option<String>,
83 pub kind: EntryKind,
84 pub package: String,
85 #[serde(default)]
86 pub owner: Option<String>,
87 #[serde(default)]
88 pub group: Option<String>,
89 #[serde(default)]
90 pub mode: Option<String>,
91 #[serde(default)]
92 pub original_owner: Option<String>,
93 #[serde(default)]
94 pub original_group: Option<String>,
95 #[serde(default)]
96 pub original_mode: Option<String>,
97}
98
99impl DeployState {
100 pub fn new(state_dir: &Path) -> Self {
101 Self {
102 version: CURRENT_VERSION,
103 state_dir: state_dir.to_path_buf(),
104 ..Default::default()
105 }
106 }
107
108 pub fn lock(&mut self) -> Result<()> {
110 std::fs::create_dir_all(&self.state_dir).with_context(|| {
111 format!(
112 "failed to create state directory: {}",
113 self.state_dir.display()
114 )
115 })?;
116 let lock_path = self.state_dir.join("dotm.lock");
117 let lock_file = std::fs::OpenOptions::new()
118 .create(true)
119 .write(true)
120 .truncate(false)
121 .open(&lock_path)
122 .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
123
124 lock_file.try_lock_exclusive().map_err(|_| {
125 anyhow::anyhow!(
126 "another dotm process is running (could not acquire lock on {})",
127 lock_path.display()
128 )
129 })?;
130
131 self.lock = Some(lock_file);
132 Ok(())
133 }
134
135 pub fn load(state_dir: &Path) -> Result<Self> {
136 let is_legacy = state_dir.file_name().map(|n| n != ".dotm").unwrap_or(true);
138 if is_legacy {
139 Self::migrate_storage(state_dir)?;
140 }
141 let path = state_dir.join(STATE_FILE);
142 if !path.exists() {
143 return Ok(Self::new(state_dir));
144 }
145 let content = std::fs::read_to_string(&path)
146 .with_context(|| format!("failed to read state file: {}", path.display()))?;
147 let mut state: DeployState = serde_json::from_str(&content)
148 .with_context(|| format!("failed to parse state file: {}", path.display()))?;
149 if state.version > CURRENT_VERSION {
150 anyhow::bail!(
151 "state file was created by a newer version of dotm (state version {}, max supported {})",
152 state.version,
153 CURRENT_VERSION
154 );
155 }
156 if state.version < CURRENT_VERSION {
157 state.version = CURRENT_VERSION;
158 }
159 state.state_dir = state_dir.to_path_buf();
160 Ok(state)
161 }
162
163 pub fn load_locked(state_dir: &Path) -> Result<Self> {
166 std::fs::create_dir_all(state_dir).with_context(|| {
167 format!("failed to create state directory: {}", state_dir.display())
168 })?;
169 let lock_path = state_dir.join("dotm.lock");
170 let lock_file = std::fs::OpenOptions::new()
171 .create(true)
172 .write(true)
173 .truncate(false)
174 .open(&lock_path)
175 .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
176
177 lock_file.try_lock_exclusive().map_err(|_| {
178 anyhow::anyhow!(
179 "another dotm process is running (could not acquire lock on {})",
180 lock_path.display()
181 )
182 })?;
183
184 let mut state = Self::load(state_dir)?;
185 state.lock = Some(lock_file);
186 Ok(state)
187 }
188
189 pub fn save(&self) -> Result<()> {
190 std::fs::create_dir_all(&self.state_dir).with_context(|| {
191 format!(
192 "failed to create state directory: {}",
193 self.state_dir.display()
194 )
195 })?;
196 let path = self.state_dir.join(STATE_FILE);
197 let tmp_path = self.state_dir.join(".dotm-state.json.tmp");
198 let content = serde_json::to_string_pretty(self)?;
199 std::fs::write(&tmp_path, &content)
200 .with_context(|| format!("failed to write temp state file: {}", tmp_path.display()))?;
201 std::fs::rename(&tmp_path, &path).with_context(|| {
202 format!(
203 "failed to rename temp state file: {} -> {}",
204 tmp_path.display(),
205 path.display()
206 )
207 })?;
208 Ok(())
209 }
210
211 pub fn record(&mut self, entry: DeployEntry) {
212 self.entries.push(entry);
213 }
214
215 pub fn entries(&self) -> &[DeployEntry] {
216 &self.entries
217 }
218
219 pub fn remove_targets(&mut self, targets: &[PathBuf]) {
220 let target_set: std::collections::HashSet<&PathBuf> = targets.iter().collect();
221 self.entries.retain(|e| !target_set.contains(&e.target));
222 }
223
224 pub fn update_entry_hash(&mut self, index: usize, new_hash: String) {
225 if let Some(entry) = self.entries.get_mut(index) {
226 entry.content_hash = new_hash;
227 }
228 }
229
230 pub fn check_entry_status(&self, entry: &DeployEntry) -> FileStatus {
231 if !entry.target.exists() && !entry.target.is_symlink() {
232 return FileStatus::missing();
233 }
234
235 let mut status = FileStatus::ok();
236
237 if entry.target.is_symlink() {
238 let link_dest = match std::fs::read_link(&entry.target) {
240 Ok(dest) => dest,
241 Err(_) => return FileStatus::missing(),
242 };
243
244 let canon_link = std::fs::canonicalize(&link_dest)
245 .or_else(|_| std::fs::canonicalize(&entry.target))
246 .unwrap_or(link_dest.clone());
247 let canon_source =
248 std::fs::canonicalize(&entry.source).unwrap_or_else(|_| entry.source.clone());
249
250 if canon_link == canon_source {
251 } else if let Some(ref staged) = entry.staged {
253 let canon_staged = std::fs::canonicalize(staged).unwrap_or_else(|_| staged.clone());
255 if canon_link == canon_staged {
256 if let Ok(current_hash) = hash::hash_file(staged)
258 && current_hash != entry.content_hash
259 {
260 status.content_modified = true;
261 }
262 } else {
263 return FileStatus::missing();
264 }
265 } else {
266 return FileStatus::missing();
267 }
268 } else {
269 if let Ok(current_hash) = hash::hash_file(&entry.target)
271 && current_hash != entry.content_hash
272 {
273 status.content_modified = true;
274 }
275 }
276
277 if let Ok((current_owner, current_group, current_mode)) =
279 crate::metadata::read_file_metadata(&entry.target)
280 {
281 if let Some(ref expected_owner) = entry.owner {
282 if current_owner != *expected_owner {
283 status.owner_changed = true;
284 }
285 }
286 if let Some(ref expected_group) = entry.group {
287 if current_group != *expected_group {
288 status.group_changed = true;
289 }
290 }
291 if let Some(ref expected_mode) = entry.mode {
292 if current_mode != *expected_mode {
293 status.mode_changed = true;
294 }
295 }
296 }
297
298 status
299 }
300
301 pub fn originals_dir(&self) -> PathBuf {
302 self.state_dir.join("originals")
303 }
304
305 pub fn store_original(&self, content_hash: &str, content: &[u8]) -> Result<()> {
306 let dir = self.originals_dir();
307 std::fs::create_dir_all(&dir)
308 .with_context(|| format!("failed to create originals directory: {}", dir.display()))?;
309 let path = dir.join(content_hash);
310 if !path.exists() {
311 std::fs::write(&path, content)
312 .with_context(|| format!("failed to store original: {}", path.display()))?;
313 }
314 Ok(())
315 }
316
317 pub fn load_original(&self, content_hash: &str) -> Result<Vec<u8>> {
318 let path = self.originals_dir().join(content_hash);
319 std::fs::read(&path)
320 .with_context(|| format!("failed to load original content: {}", path.display()))
321 }
322
323 pub fn migrate_storage(state_dir: &Path) -> Result<()> {
324 let originals = state_dir.join("originals");
325 let deployed = state_dir.join("deployed");
326 if originals.is_dir() && !deployed.exists() {
327 std::fs::rename(&originals, &deployed)
328 .with_context(|| "failed to migrate originals/ to deployed/")?;
329 }
330 Ok(())
331 }
332
333 pub fn restore(&mut self, package_filter: Option<&str>) -> Result<usize> {
338 let mut restored = 0;
339 let mut remaining = Vec::new();
340 let mut restore_error: Option<anyhow::Error> = None;
341
342 for entry in &self.entries {
343 if let Some(filter) = package_filter {
344 if entry.package != filter {
345 remaining.push(entry.clone());
346 continue;
347 }
348 }
349
350 if restore_error.is_some() {
352 remaining.push(entry.clone());
353 continue;
354 }
355
356 let result: Result<()> = (|| {
357 if let Some(ref orig_hash) = entry.original_hash {
358 let original_content = self.load_original(orig_hash)?;
359 std::fs::write(&entry.target, &original_content).with_context(|| {
360 format!("failed to restore: {}", entry.target.display())
361 })?;
362
363 if entry.original_owner.is_some() || entry.original_group.is_some() {
364 let _ = crate::metadata::apply_ownership(
365 &entry.target,
366 entry.original_owner.as_deref(),
367 entry.original_group.as_deref(),
368 );
369 }
370 if let Some(ref orig_mode) = entry.original_mode {
371 let _ =
372 crate::deployer::apply_permission_override(&entry.target, orig_mode);
373 }
374 } else if entry.target.exists() || entry.target.is_symlink() {
375 std::fs::remove_file(&entry.target)
376 .with_context(|| format!("failed to remove: {}", entry.target.display()))?;
377 cleanup_empty_parents(&entry.target);
378 }
379 Ok(())
380 })();
381
382 match result {
383 Ok(()) => restored += 1,
384 Err(e) => {
385 remaining.push(entry.clone());
386 restore_error = Some(e);
387 }
388 }
389 }
390
391 if package_filter.is_some() {
392 self.entries = remaining;
393 if let Err(save_err) = self.save() {
394 eprintln!("warning: failed to save state after partial restore: {save_err}");
395 }
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 } else {
406 self.entries = remaining;
408 if let Err(save_err) = self.save() {
409 eprintln!("warning: failed to save state after partial restore: {save_err}");
410 }
411 }
412
413 if let Some(e) = restore_error {
414 return Err(e);
415 }
416
417 Ok(restored)
418 }
419
420 pub fn undeploy_package(
427 &mut self,
428 package: &str,
429 packages: Option<&HashMap<String, crate::config::PackageConfig>>,
430 cwd: &Path,
431 ) -> Result<usize> {
432 if let Some(pkgs) = packages {
434 if let Some(pkg_config) = pkgs.get(package) {
435 if let Some(ref cmd) = pkg_config.pre_undeploy {
436 crate::hooks::run_hook(cmd, cwd, package, "undeploy")?;
437 }
438 }
439 }
440
441 let mut removed = 0;
442 let mut remaining = Vec::new();
443
444 for entry in &self.entries {
445 if entry.package == package {
446 if entry.target.is_symlink() || entry.target.exists() {
447 std::fs::remove_file(&entry.target).with_context(|| {
448 format!("failed to remove target: {}", entry.target.display())
449 })?;
450 cleanup_empty_parents(&entry.target);
451 removed += 1;
452 }
453 } else {
454 remaining.push(entry.clone());
455 }
456 }
457
458 self.entries = remaining;
459 self.save()?;
460
461 if let Some(pkgs) = packages {
463 if let Some(pkg_config) = pkgs.get(package) {
464 if let Some(ref cmd) = pkg_config.post_undeploy {
465 if let Err(e) = crate::hooks::run_hook(cmd, cwd, package, "undeploy") {
466 eprintln!("warning: {e}");
467 }
468 }
469 }
470 }
471
472 Ok(removed)
473 }
474
475 pub fn undeploy(
481 &self,
482 packages: Option<&HashMap<String, crate::config::PackageConfig>>,
483 cwd: &Path,
484 ) -> Result<usize> {
485 let mut unique_packages: Vec<&str> = Vec::new();
489 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
490 for entry in &self.entries {
491 if seen.insert(&entry.package) {
492 unique_packages.push(&entry.package);
493 }
494 }
495
496 if let Some(pkgs) = packages {
498 for pkg_name in &unique_packages {
499 if let Some(pkg_config) = pkgs.get(*pkg_name) {
500 if let Some(ref cmd) = pkg_config.pre_undeploy {
501 crate::hooks::run_hook(cmd, cwd, pkg_name, "undeploy")?;
502 }
503 }
504 }
505 }
506
507 let mut removed = 0;
508
509 for entry in &self.entries {
510 if entry.target.is_symlink() || entry.target.exists() {
511 std::fs::remove_file(&entry.target).with_context(|| {
512 format!("failed to remove target: {}", entry.target.display())
513 })?;
514 cleanup_empty_parents(&entry.target);
515 removed += 1;
516 }
517 }
518
519 let originals = self.originals_dir();
521 if originals.is_dir() {
522 let _ = std::fs::remove_dir_all(&originals);
523 }
524
525 let state_path = self.state_dir.join(STATE_FILE);
527 if state_path.exists() {
528 std::fs::remove_file(&state_path)?;
529 }
530
531 if let Some(pkgs) = packages {
533 for pkg_name in &unique_packages {
534 if let Some(pkg_config) = pkgs.get(*pkg_name) {
535 if let Some(ref cmd) = pkg_config.post_undeploy {
536 if let Err(e) = crate::hooks::run_hook(cmd, cwd, pkg_name, "undeploy") {
537 eprintln!("warning: {e}");
538 }
539 }
540 }
541 }
542 }
543
544 Ok(removed)
545 }
546}
547
548pub fn cleanup_empty_parents(path: &Path) {
549 let mut current = path.parent();
550 while let Some(parent) = current {
551 if parent == Path::new("") || parent == Path::new("/") {
552 break;
553 }
554 match std::fs::read_dir(parent) {
555 Ok(mut entries) => {
556 if entries.next().is_none() {
557 let _ = std::fs::remove_dir(parent);
558 current = parent.parent();
559 } else {
560 break;
561 }
562 }
563 Err(_) => break,
564 }
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use tempfile::TempDir;
572
573 #[test]
574 fn store_and_load_original_content() {
575 let dir = TempDir::new().unwrap();
576 let state = DeployState::new(dir.path());
577 state
578 .store_original("orig456", b"original pre-existing content")
579 .unwrap();
580 let loaded = state.load_original("orig456").unwrap();
581 assert_eq!(loaded, b"original pre-existing content");
582 }
583
584 #[test]
585 fn migrate_renames_originals_to_deployed() {
586 let dir = TempDir::new().unwrap();
587 let originals = dir.path().join("originals");
588 std::fs::create_dir_all(&originals).unwrap();
589 std::fs::write(originals.join("hash1"), "content1").unwrap();
590
591 DeployState::migrate_storage(dir.path()).unwrap();
592
593 assert!(!originals.exists());
594 let deployed = dir.path().join("deployed");
595 assert!(deployed.exists());
596 assert_eq!(
597 std::fs::read_to_string(deployed.join("hash1")).unwrap(),
598 "content1"
599 );
600 }
601
602 #[test]
603 fn migrate_noop_if_deployed_exists() {
604 let dir = TempDir::new().unwrap();
605 let deployed = dir.path().join("deployed");
606 std::fs::create_dir_all(&deployed).unwrap();
607 std::fs::write(deployed.join("hash1"), "existing").unwrap();
608
609 let originals = dir.path().join("originals");
610 std::fs::create_dir_all(&originals).unwrap();
611 std::fs::write(originals.join("hash1"), "should not replace").unwrap();
612
613 DeployState::migrate_storage(dir.path()).unwrap();
614
615 assert_eq!(
616 std::fs::read_to_string(deployed.join("hash1")).unwrap(),
617 "existing"
618 );
619 }
620
621 #[test]
622 fn concurrent_lock_fails() {
623 use fs2::FileExt;
624 let dir = TempDir::new().unwrap();
625 std::fs::create_dir_all(dir.path()).unwrap();
626 let lock_path = dir.path().join("dotm.lock");
627 std::fs::write(&lock_path, "").unwrap();
628
629 let f = std::fs::File::open(&lock_path).unwrap();
631 f.lock_exclusive().unwrap();
632
633 let result = DeployState::load_locked(dir.path());
635 assert!(result.is_err());
636 assert!(
637 result
638 .unwrap_err()
639 .to_string()
640 .contains("another dotm process")
641 );
642 }
643}