1use std::fs::{self, File, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use crate::domain::{ProductId, ReleaseId};
7use crate::error::{Error, Result};
8
9static NEXT_LOCK_NONCE: AtomicU64 = AtomicU64::new(0);
10
11const MAX_LOCK_BYTES: u64 = 4096;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum LockStatus {
16 Available,
18 Held {
21 lock: PathBuf,
23 contents: Option<String>,
25 },
26 Malformed {
29 lock: PathBuf,
31 },
32}
33
34#[derive(Debug)]
44pub struct MutationLock {
45 path: PathBuf,
46 token: String,
47 cleanup: bool,
48 _file: File,
49}
50
51impl MutationLock {
52 pub fn acquire(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Self> {
54 let path = root.join(".eggup-mutation.lock");
55 let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
56 let token = format!(
57 "pid={} nonce={} product={} release={}\n",
58 std::process::id(),
59 nonce,
60 product,
61 release
62 );
63 if token.len() as u64 > MAX_LOCK_BYTES {
64 return Err(Error::invalid("lock record exceeds bound"));
65 }
66 let mut file = OpenOptions::new()
67 .write(true)
68 .create_new(true)
69 .open(&path)
70 .map_err(|source| {
71 if source.kind() == std::io::ErrorKind::AlreadyExists {
72 Error::UpdateInProgress { lock: path.clone() }
73 } else {
74 Error::io("creating mutation lock", source)
75 }
76 })?;
77 if let Err(source) = file.write_all(token.as_bytes()) {
78 let _ = fs::remove_file(&path);
79 return Err(Error::io("writing mutation lock", source));
80 }
81 #[cfg(unix)]
82 {
83 use std::os::unix::fs::PermissionsExt;
84 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
85 }
86 Ok(Self {
87 path,
88 token,
89 cleanup: true,
90 _file: file,
91 })
92 }
93
94 pub fn inspect(root: &Path) -> Result<LockStatus> {
101 let path = root.join(".eggup-mutation.lock");
102 let meta = match fs::symlink_metadata(&path) {
103 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
104 return Ok(LockStatus::Available);
105 }
106 Err(source) => return Err(Error::io("reading mutation lock", source)),
107 Ok(m) => m,
108 };
109 if !meta.is_file() {
110 return Ok(LockStatus::Malformed { lock: path });
111 }
112 #[cfg(unix)]
113 {
114 if meta.file_type().is_symlink() {
115 return Ok(LockStatus::Malformed { lock: path });
116 }
117 }
118 if meta.len() > MAX_LOCK_BYTES {
119 return Ok(LockStatus::Malformed { lock: path });
120 }
121 match fs::read_to_string(&path) {
122 Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(LockStatus::Held {
123 lock: path,
124 contents: Some(contents),
125 }),
126 _ => Ok(LockStatus::Malformed { lock: path }),
127 }
128 }
129
130 pub fn path(&self) -> &Path {
132 &self.path
133 }
134
135 pub(crate) fn preserve(&mut self) {
137 self.cleanup = false;
138 }
139}
140
141impl Drop for MutationLock {
142 fn drop(&mut self) {
143 if !self.cleanup {
144 return;
145 }
146 let Ok(metadata) = fs::symlink_metadata(&self.path) else {
147 return;
148 };
149 if !metadata.is_file() {
150 return;
151 }
152 let Ok(contents) = fs::read_to_string(&self.path) else {
153 return;
154 };
155 if contents == self.token {
156 let _ = fs::remove_file(&self.path);
157 }
158 }
159}