1use std::fs;
8use std::io::Write;
9use std::path::Path;
10use std::time::SystemTime;
11
12use tracing::{info, warn};
13
14use super::FileStore;
15use crate::error::GettextError;
16
17pub(crate) const TMP_FILE_PREFIX: &str = ".gettext-mcp-";
19pub(crate) const TMP_FILE_SUFFIX: &str = ".tmp";
21
22fn strip_bom(content: &str) -> &str {
24 content.strip_prefix('\u{feff}').unwrap_or(content)
25}
26
27pub fn cleanup_orphan_tmps(dir: &Path) {
30 let Ok(entries) = fs::read_dir(dir) else {
31 return;
32 };
33 for entry in entries.flatten() {
34 let name = entry.file_name();
35 let name_str = name.to_string_lossy();
36 if name_str.starts_with(TMP_FILE_PREFIX) && name_str.ends_with(TMP_FILE_SUFFIX) {
37 let path = entry.path();
38 match fs::remove_file(&path) {
39 Ok(()) => info!("cleaned up orphan temp file: {}", path.display()),
40 Err(e) => warn!(
41 "failed to remove orphan temp file {}: {}",
42 path.display(),
43 e
44 ),
45 }
46 }
47 }
48}
49
50#[derive(Debug, Default)]
54pub struct FsFileStore;
55
56impl FsFileStore {
57 pub fn new() -> Self {
58 Self
59 }
60}
61
62impl FileStore for FsFileStore {
63 fn read(&self, path: &Path) -> Result<String, GettextError> {
64 let content = fs::read_to_string(path)?;
65 Ok(strip_bom(&content).to_string())
66 }
67
68 fn write(&self, path: &Path, content: &str) -> Result<(), GettextError> {
69 atomic_write(path, content.as_bytes())
70 }
71
72 fn write_bytes(&self, path: &Path, bytes: &[u8]) -> Result<(), GettextError> {
73 atomic_write(path, bytes)
74 }
75
76 fn modified_time(&self, path: &Path) -> Result<SystemTime, GettextError> {
77 let meta = fs::metadata(path)?;
78 Ok(meta.modified()?)
79 }
80
81 fn exists(&self, path: &Path) -> bool {
82 path.exists()
83 }
84}
85
86fn atomic_write(target: &Path, content: &[u8]) -> Result<(), GettextError> {
91 let dir = target.parent().ok_or_else(|| {
92 GettextError::InvalidPath(format!(
93 "no parent directory for target path: {}",
94 target.display()
95 ))
96 })?;
97
98 let _lock_guard = acquire_flock(target)?;
99
100 static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
103 let seq = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
104 let tmp_name = format!(
105 "{}{}-{}-{}{}",
106 TMP_FILE_PREFIX,
107 std::process::id(),
108 SystemTime::now()
109 .duration_since(SystemTime::UNIX_EPOCH)
110 .map(|d| d.as_millis())
111 .unwrap_or(0),
112 seq,
113 TMP_FILE_SUFFIX,
114 );
115 let tmp_path = dir.join(&tmp_name);
116
117 let result = (|| -> Result<(), GettextError> {
118 let mut file = fs::File::create(&tmp_path)?;
119 file.write_all(content)?;
120 file.sync_all()?;
121 fs::rename(&tmp_path, target)?;
124 Ok(())
125 })();
126
127 if result.is_err() {
128 let _ = fs::remove_file(&tmp_path);
130 }
131
132 result
133}
134
135#[cfg(unix)]
139struct FlockGuard {
140 _file: fs::File,
141}
142
143#[cfg(unix)]
144fn acquire_flock(target: &Path) -> Result<Option<FlockGuard>, GettextError> {
145 use std::os::unix::io::AsRawFd;
146
147 if !target.exists() {
148 return Ok(None);
149 }
150
151 let file = match fs::File::open(target) {
152 Ok(f) => f,
153 Err(e) => {
154 warn!(
155 "could not open {} for advisory lock: {} — proceeding without lock",
156 target.display(),
157 e
158 );
159 return Ok(None);
160 }
161 };
162
163 let fd = file.as_raw_fd();
164 let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
166 if ret != 0 {
167 let errno = std::io::Error::last_os_error();
168 if errno.kind() == std::io::ErrorKind::WouldBlock {
169 return Err(GettextError::FileLocked {
170 path: target.to_path_buf(),
171 });
172 }
173 warn!(
174 "advisory flock unavailable for {}: {} — proceeding without lock",
175 target.display(),
176 errno
177 );
178 return Ok(None);
179 }
180
181 Ok(Some(FlockGuard { _file: file }))
182}
183
184#[cfg(not(unix))]
185struct FlockGuard;
186
187#[cfg(not(unix))]
188fn acquire_flock(_target: &Path) -> Result<Option<FlockGuard>, GettextError> {
189 Ok(None)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn cleanup_orphan_tmps_removes_only_matching_files() {
198 let dir = tempfile::TempDir::new().unwrap();
199 let orphan = dir
200 .path()
201 .join(format!("{TMP_FILE_PREFIX}123-456{TMP_FILE_SUFFIX}"));
202 let keep = dir.path().join("messages.po");
203 std::fs::write(&orphan, b"junk").unwrap();
204 std::fs::write(&keep, b"msgid \"\"\nmsgstr \"\"\n").unwrap();
205
206 cleanup_orphan_tmps(dir.path());
207
208 assert!(!orphan.exists(), "orphan temp file should be removed");
209 assert!(keep.exists(), "unrelated .po file must be preserved");
210 }
211
212 #[test]
213 fn write_then_read_roundtrip() {
214 let dir = tempfile::TempDir::new().unwrap();
215 let path = dir.path().join("hello.po");
216 let store = FsFileStore::new();
217
218 store
219 .write(&path, "msgid \"Hi\"\nmsgstr \"Hej\"\n")
220 .unwrap();
221 let content = store.read(&path).unwrap();
222 assert!(content.contains("Hej"));
223 }
224
225 #[test]
226 fn read_strips_leading_bom() {
227 let dir = tempfile::TempDir::new().unwrap();
228 let path = dir.path().join("bom.po");
229 let body = "msgid \"\"\nmsgstr \"\"\n";
230 let with_bom = format!("\u{feff}{body}");
231 std::fs::write(&path, with_bom.as_bytes()).unwrap();
232
233 let store = FsFileStore::new();
234 let read = store.read(&path).unwrap();
235 assert!(!read.starts_with('\u{feff}'));
236 assert_eq!(read, body);
237 }
238
239 #[test]
240 fn atomic_write_leaves_no_orphan_tmp_files() {
241 let dir = tempfile::TempDir::new().unwrap();
242 let path = dir.path().join("clean.po");
243 let store = FsFileStore::new();
244
245 store.write(&path, "msgid \"a\"\nmsgstr \"b\"\n").unwrap();
246 store.write(&path, "msgid \"c\"\nmsgstr \"d\"\n").unwrap();
247
248 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
249 .unwrap()
250 .filter_map(|e| e.ok())
251 .filter(|e| e.file_name().to_string_lossy().ends_with(TMP_FILE_SUFFIX))
252 .collect();
253 assert!(leftovers.is_empty(), "found orphan tmp files");
254 }
255
256 #[cfg(unix)]
257 #[test]
258 fn flock_blocks_concurrent_write() {
259 use std::os::unix::io::AsRawFd;
260
261 let dir = tempfile::TempDir::new().unwrap();
262 let path = dir.path().join("locked.po");
263 let store = FsFileStore::new();
264
265 store.write(&path, "initial").unwrap();
266
267 let lock_file = fs::File::open(&path).unwrap();
268 let fd = lock_file.as_raw_fd();
269 let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
271 assert_eq!(ret, 0);
272
273 let err = store.write(&path, "updated").unwrap_err();
274 assert!(
275 matches!(err, GettextError::FileLocked { .. }),
276 "expected FileLocked, got: {err}"
277 );
278
279 unsafe { libc::flock(fd, libc::LOCK_UN) };
281 drop(lock_file);
282
283 store.write(&path, "updated").unwrap();
284 let content = store.read(&path).unwrap();
285 assert_eq!(content, "updated");
286 }
287
288 #[test]
289 fn modified_time_returns_recent_value() {
290 let dir = tempfile::TempDir::new().unwrap();
291 let path = dir.path().join("timed.po");
292 let store = FsFileStore::new();
293
294 store.write(&path, "content").unwrap();
295 let mtime = store.modified_time(&path).unwrap();
296 let elapsed = SystemTime::now().duration_since(mtime).unwrap();
297 assert!(elapsed.as_secs() < 5);
298 }
299
300 #[test]
301 fn write_bytes_preserves_binary_content() {
302 let dir = tempfile::TempDir::new().unwrap();
303 let path = dir.path().join("data.bin");
304 let store = FsFileStore::new();
305
306 let bytes: Vec<u8> = vec![0xde, 0x12, 0x04, 0x95, 0x00, 0xff, 0x01];
307 store.write_bytes(&path, &bytes).unwrap();
308 let read_back = std::fs::read(&path).unwrap();
309 assert_eq!(read_back, bytes);
310 }
311
312 #[test]
313 fn exists_reflects_filesystem() {
314 let dir = tempfile::TempDir::new().unwrap();
315 let path = dir.path().join("ex.po");
316 let store = FsFileStore::new();
317 assert!(!store.exists(&path));
318 store.write(&path, "x").unwrap();
319 assert!(store.exists(&path));
320 }
321}