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 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 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 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 } else if let Some(ref staged) = entry.staged {
213 let canon_staged = std::fs::canonicalize(staged)
215 .unwrap_or_else(|_| staged.clone());
216 if canon_link == canon_staged {
217 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 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 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 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 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 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 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 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 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 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 let originals = self.originals_dir();
395 if originals.is_dir() {
396 let _ = std::fs::remove_dir_all(&originals);
397 }
398
399 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 let f = std::fs::File::open(&lock_path).unwrap();
484 f.lock_exclusive().unwrap();
485
486 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}