Skip to main content

gettext_mcp/io/
fs.rs

1//! Production filesystem-backed [`FileStore`] implementation.
2//!
3//! Includes atomic writes via temp-file + fsync + rename, best-effort
4//! POSIX advisory locking (`flock`) during writes, leading-BOM stripping
5//! on reads, and orphan temp-file cleanup at startup.
6
7use 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
17/// Prefix used for temporary files created during atomic writes.
18pub(crate) const TMP_FILE_PREFIX: &str = ".gettext-mcp-";
19/// Suffix used for temporary files created during atomic writes.
20pub(crate) const TMP_FILE_SUFFIX: &str = ".tmp";
21
22/// Strip a leading UTF-8 BOM (`\u{feff}`) if present.
23fn strip_bom(content: &str) -> &str {
24    content.strip_prefix('\u{feff}').unwrap_or(content)
25}
26
27/// Best-effort cleanup of orphan `.gettext-mcp-*.tmp` files left over from
28/// crashed writes. Scans the given directory non-recursively.
29pub 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/// Default filesystem-backed file store: every method calls straight
51/// through to `std::fs` with the safety extras described in the module
52/// docs.
53#[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
86/// Atomically write `content` to `target` using a temp-file + fsync + rename
87/// sequence. On Unix, acquires a best-effort `flock(LOCK_EX | LOCK_NB)` on
88/// the existing target for the duration of the write. If the lock is held by
89/// another process, returns [`GettextError::FileLocked`].
90fn 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    // Process-wide monotonic counter so concurrent writers in the same
101    // process can't collide on the millisecond resolution of SystemTime.
102    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        // `rename` is atomic on POSIX when source and target are on the same
122        // filesystem — we always write the temp file in the same directory.
123        fs::rename(&tmp_path, target)?;
124        Ok(())
125    })();
126
127    if result.is_err() {
128        // Best-effort cleanup of the temp file when write/rename fails.
129        let _ = fs::remove_file(&tmp_path);
130    }
131
132    result
133}
134
135/// RAII guard for an `flock`-acquired file descriptor on Unix. The lock is
136/// released automatically when the guard is dropped (the kernel releases
137/// the lock when the underlying fd is closed).
138#[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    // SAFETY: `flock` is a POSIX syscall; `fd` is valid because `file` is alive.
165    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        // SAFETY: fd lives as long as lock_file.
270        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        // SAFETY: fd lives as long as lock_file.
280        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}