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 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 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 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 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 } else if let Some(ref staged) = entry.staged {
255 let canon_staged = std::fs::canonicalize(staged).unwrap_or_else(|_| staged.clone());
257 if canon_link == canon_staged {
258 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 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 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 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 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 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 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 let originals = self.originals_dir();
455 if originals.is_dir() {
456 let _ = std::fs::remove_dir_all(&originals);
457 }
458
459 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 let f = std::fs::File::open(&lock_path).unwrap();
552 f.lock_exclusive().unwrap();
553
554 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}