Skip to main content

xcstrings_mcp/io/
fs.rs

1use std::fs;
2use std::io::Write;
3use std::os::unix::io::AsRawFd;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use tracing::{info, warn};
8
9use crate::error::XcStringsError;
10
11use super::FileStore;
12
13pub struct FsFileStore {
14    max_file_size: u64,
15}
16
17impl Default for FsFileStore {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl FsFileStore {
24    pub fn new() -> Self {
25        let max_mb = std::env::var("XCSTRINGS_MAX_FILE_SIZE_MB")
26            .ok()
27            .and_then(|v| v.parse::<u64>().ok())
28            .unwrap_or(50);
29
30        // Cleanup orphan temp files from previous crashes
31        if let Ok(cwd) = std::env::current_dir()
32            && let Ok(entries) = fs::read_dir(&cwd)
33        {
34            for entry in entries.flatten() {
35                let name = entry.file_name();
36                let name_str = name.to_string_lossy();
37                if name_str.starts_with(".xcstrings-mcp-") && name_str.ends_with(".tmp") {
38                    let _ = fs::remove_file(entry.path());
39                    info!("cleaned up orphan temp file: {}", name_str);
40                }
41            }
42        }
43
44        Self {
45            max_file_size: max_mb * 1024 * 1024,
46        }
47    }
48
49    fn validate_path(&self, path: &Path) -> Result<PathBuf, XcStringsError> {
50        // Reject path traversal: check for ".." components BEFORE canonicalization
51        for component in path.components() {
52            if matches!(component, std::path::Component::ParentDir) {
53                return Err(XcStringsError::InvalidPath {
54                    path: path.to_path_buf(),
55                    reason: "path traversal detected (contains '..')".into(),
56                });
57            }
58        }
59
60        // Canonicalize (works for existing files)
61        let canonical = match fs::canonicalize(path) {
62            Ok(p) => p,
63            Err(_) => {
64                // File may not exist yet (write case) — canonicalize parent, append filename
65                let parent = path.parent().ok_or_else(|| XcStringsError::InvalidPath {
66                    path: path.to_path_buf(),
67                    reason: "no parent directory".into(),
68                })?;
69                let filename = path
70                    .file_name()
71                    .ok_or_else(|| XcStringsError::InvalidPath {
72                        path: path.to_path_buf(),
73                        reason: "no filename".into(),
74                    })?;
75                let canonical_parent =
76                    fs::canonicalize(parent).map_err(|_| XcStringsError::InvalidPath {
77                        path: path.to_path_buf(),
78                        reason: "parent directory does not exist".into(),
79                    })?;
80                canonical_parent.join(filename)
81            }
82        };
83
84        Ok(canonical)
85    }
86
87    fn strip_bom(content: &str) -> &str {
88        content.strip_prefix('\u{feff}').unwrap_or(content)
89    }
90}
91
92impl FileStore for FsFileStore {
93    fn read(&self, path: &Path) -> Result<String, XcStringsError> {
94        let canonical = self.validate_path(path)?;
95
96        if !canonical.exists() {
97            return Err(XcStringsError::FileNotFound { path: canonical });
98        }
99
100        let metadata = fs::metadata(&canonical)?;
101        let size = metadata.len();
102        if size > self.max_file_size {
103            return Err(XcStringsError::FileTooLarge {
104                size_mb: size / (1024 * 1024),
105                max_mb: self.max_file_size / (1024 * 1024),
106            });
107        }
108
109        let content = fs::read_to_string(&canonical)?;
110        Ok(Self::strip_bom(&content).to_string())
111    }
112
113    fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, XcStringsError> {
114        let canonical = self.validate_path(path)?;
115
116        if !canonical.exists() {
117            return Err(XcStringsError::FileNotFound { path: canonical });
118        }
119
120        let metadata = fs::metadata(&canonical)?;
121        let size = metadata.len();
122        if size > self.max_file_size {
123            return Err(XcStringsError::FileTooLarge {
124                size_mb: size / (1024 * 1024),
125                max_mb: self.max_file_size / (1024 * 1024),
126            });
127        }
128
129        Ok(fs::read(&canonical)?)
130    }
131
132    fn write(&self, path: &Path, content: &str) -> Result<(), XcStringsError> {
133        let canonical = self.validate_path(path)?;
134        let dir = canonical
135            .parent()
136            .ok_or_else(|| XcStringsError::InvalidPath {
137                path: canonical.clone(),
138                reason: "no parent directory".into(),
139            })?;
140
141        // Acquire advisory lock on target file (best-effort: skip if file doesn't exist yet)
142        let _lock_file = if canonical.exists() {
143            let lock_file = fs::File::open(&canonical)?;
144            let fd = lock_file.as_raw_fd();
145            // SAFETY: flock is a POSIX syscall, fd is valid because lock_file is alive
146            let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
147            if ret != 0 {
148                let errno = std::io::Error::last_os_error();
149                if errno.kind() == std::io::ErrorKind::WouldBlock {
150                    return Err(XcStringsError::FileLocked { path: canonical });
151                }
152                // Non-blocking lock not supported (e.g. network FS) — proceed without lock
153                warn!(
154                    "advisory flock unavailable for {}: {errno} — proceeding without lock",
155                    canonical.display()
156                );
157                None
158            } else {
159                Some(lock_file)
160            }
161        } else {
162            None
163        };
164
165        let tmp_name = format!(
166            ".xcstrings-mcp-{}-{}.tmp",
167            std::process::id(),
168            SystemTime::now()
169                .duration_since(SystemTime::UNIX_EPOCH)
170                .map(|d| d.as_millis())
171                .unwrap_or(0)
172        );
173        let tmp_path = dir.join(&tmp_name);
174
175        // Write to temp file, fsync, then atomic rename
176        let result = (|| -> Result<(), XcStringsError> {
177            let mut file = fs::File::create(&tmp_path)?;
178            file.write_all(content.as_bytes())?;
179            file.sync_all()?;
180            fs::rename(&tmp_path, &canonical)?;
181            Ok(())
182        })();
183
184        // Clean up temp file on failure
185        if result.is_err() {
186            let _ = fs::remove_file(&tmp_path);
187        }
188
189        // Lock is released when _lock_file is dropped
190        result?;
191
192        info!("wrote {} bytes to {}", content.len(), canonical.display());
193        Ok(())
194    }
195
196    fn modified_time(&self, path: &Path) -> Result<SystemTime, XcStringsError> {
197        let canonical = self.validate_path(path)?;
198        let metadata = fs::metadata(&canonical)?;
199        Ok(metadata.modified()?)
200    }
201
202    fn exists(&self, path: &Path) -> bool {
203        path.exists()
204    }
205
206    fn create_parent_dirs(&self, path: &Path) -> Result<(), XcStringsError> {
207        fs::create_dir_all(path.parent().unwrap_or(path))?;
208        Ok(())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use tempfile::TempDir;
216
217    #[test]
218    fn test_read_write_roundtrip() {
219        let dir = TempDir::new().unwrap();
220        let file_path = dir.path().join("test.xcstrings");
221        let store = FsFileStore::new();
222
223        let content = r#"{"sourceLanguage":"en","strings":{},"version":"1.0"}"#;
224        store.write(&file_path, content).unwrap();
225
226        let read_back = store.read(&file_path).unwrap();
227        assert_eq!(read_back, content);
228    }
229
230    #[test]
231    fn test_bom_stripping() {
232        let dir = TempDir::new().unwrap();
233        let file_path = dir.path().join("bom.xcstrings");
234
235        let content = "hello world";
236        let with_bom = format!("\u{feff}{content}");
237        std::fs::write(&file_path, with_bom.as_bytes()).unwrap();
238
239        let store = FsFileStore::new();
240        let read_back = store.read(&file_path).unwrap();
241        assert_eq!(read_back, content);
242    }
243
244    #[test]
245    fn test_file_too_large() {
246        let dir = TempDir::new().unwrap();
247        let file_path = dir.path().join("big.xcstrings");
248        std::fs::write(&file_path, "ab").unwrap();
249
250        let store = FsFileStore {
251            max_file_size: 1, // 1 byte max
252        };
253        let err = store.read(&file_path).unwrap_err();
254        assert!(
255            matches!(err, XcStringsError::FileTooLarge { .. }),
256            "expected FileTooLarge, got: {err}"
257        );
258    }
259
260    #[test]
261    fn test_path_traversal_rejected() {
262        let store = FsFileStore::new();
263        // ".." components are rejected before canonicalization
264        let result = store.validate_path(Path::new("/tmp/../etc/passwd"));
265        assert!(result.is_err(), "path traversal should be rejected");
266        let err = result.unwrap_err();
267        assert!(
268            matches!(err, XcStringsError::InvalidPath { .. }),
269            "expected InvalidPath, got: {err}"
270        );
271    }
272
273    #[test]
274    fn test_file_not_found() {
275        let dir = TempDir::new().unwrap();
276        let file_path = dir.path().join("nope.xcstrings");
277        let store = FsFileStore::new();
278
279        let err = store.read(&file_path).unwrap_err();
280        assert!(
281            matches!(err, XcStringsError::FileNotFound { .. }),
282            "expected FileNotFound, got: {err}"
283        );
284    }
285
286    #[test]
287    fn test_validate_path_no_parent() {
288        let store = FsFileStore::new();
289        let result = store.validate_path(Path::new(""));
290        assert!(result.is_err());
291    }
292
293    #[test]
294    fn test_validate_path_parent_not_exists() {
295        let store = FsFileStore::new();
296        let result = store.validate_path(Path::new("/no_such_parent_dir_xyz/file.txt"));
297        assert!(result.is_err());
298        let err = result.unwrap_err();
299        assert!(
300            matches!(err, XcStringsError::InvalidPath { .. }),
301            "expected InvalidPath, got: {err}"
302        );
303    }
304
305    #[test]
306    fn test_write_creates_file() {
307        let dir = TempDir::new().unwrap();
308        let file_path = dir.path().join("new_file.xcstrings");
309        let store = FsFileStore::new();
310
311        assert!(!file_path.exists());
312        store.write(&file_path, "content").unwrap();
313        assert!(file_path.exists());
314        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "content");
315    }
316
317    #[test]
318    fn test_modified_time() {
319        let dir = TempDir::new().unwrap();
320        let file_path = dir.path().join("timed.xcstrings");
321        let store = FsFileStore::new();
322
323        store.write(&file_path, "content").unwrap();
324        let mtime = store.modified_time(&file_path).unwrap();
325        let elapsed = SystemTime::now().duration_since(mtime).unwrap();
326        assert!(elapsed.as_secs() < 5);
327    }
328
329    #[test]
330    fn test_exists() {
331        let dir = TempDir::new().unwrap();
332        let file_path = dir.path().join("exists.xcstrings");
333        let store = FsFileStore::new();
334
335        assert!(!store.exists(&file_path));
336        store.write(&file_path, "content").unwrap();
337        assert!(store.exists(&file_path));
338    }
339
340    #[test]
341    fn test_default_impl() {
342        let store = FsFileStore::default();
343        assert!(!store.exists(Path::new("/nonexistent")));
344    }
345
346    #[test]
347    fn test_flock_blocks_concurrent_write() {
348        let dir = TempDir::new().unwrap();
349        let file_path = dir.path().join("locked.xcstrings");
350        let store = FsFileStore::new();
351
352        // Create the file first
353        store.write(&file_path, "initial").unwrap();
354
355        // Hold an exclusive lock on the file
356        let lock_file = fs::File::open(&file_path).unwrap();
357        let fd = lock_file.as_raw_fd();
358        // SAFETY: fd is valid, lock_file is alive
359        let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
360        assert_eq!(ret, 0, "should acquire lock");
361
362        // Attempt to write while locked — should fail with FileLocked
363        let err = store.write(&file_path, "updated").unwrap_err();
364        assert!(
365            matches!(err, XcStringsError::FileLocked { .. }),
366            "expected FileLocked, got: {err}"
367        );
368
369        // Release lock
370        // SAFETY: fd is valid, lock_file is alive
371        unsafe { libc::flock(fd, libc::LOCK_UN) };
372        drop(lock_file);
373
374        // Now write should succeed
375        store.write(&file_path, "updated").unwrap();
376        let content = store.read(&file_path).unwrap();
377        assert_eq!(content, "updated");
378    }
379
380    #[test]
381    fn test_read_bytes_roundtrip() {
382        let dir = TempDir::new().unwrap();
383        let file_path = dir.path().join("binary.dat");
384        let content = b"\x00\x01\x02\xFF\xFE\xFD";
385        std::fs::write(&file_path, content).unwrap();
386
387        let store = FsFileStore::new();
388        let read_back = store.read_bytes(&file_path).unwrap();
389        assert_eq!(read_back, content);
390    }
391
392    #[test]
393    fn test_read_bytes_file_not_found() {
394        let dir = TempDir::new().unwrap();
395        let file_path = dir.path().join("nope.bin");
396        let store = FsFileStore::new();
397
398        let err = store.read_bytes(&file_path).unwrap_err();
399        assert!(
400            matches!(err, XcStringsError::FileNotFound { .. }),
401            "expected FileNotFound, got: {err}"
402        );
403    }
404
405    #[test]
406    fn test_read_bytes_file_too_large() {
407        let dir = TempDir::new().unwrap();
408        let file_path = dir.path().join("big.bin");
409        std::fs::write(&file_path, b"ab").unwrap();
410
411        let store = FsFileStore {
412            max_file_size: 1, // 1 byte max
413        };
414        let err = store.read_bytes(&file_path).unwrap_err();
415        assert!(
416            matches!(err, XcStringsError::FileTooLarge { .. }),
417            "expected FileTooLarge, got: {err}"
418        );
419    }
420
421    #[test]
422    fn test_atomic_write_no_orphans() {
423        let dir = TempDir::new().unwrap();
424        let file_path = dir.path().join("clean.xcstrings");
425        let store = FsFileStore::new();
426
427        store.write(&file_path, "content").unwrap();
428
429        // No .tmp files should remain
430        let tmp_files: Vec<_> = std::fs::read_dir(dir.path())
431            .unwrap()
432            .filter_map(|e| e.ok())
433            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
434            .collect();
435        assert!(
436            tmp_files.is_empty(),
437            "orphan tmp files found: {tmp_files:?}"
438        );
439    }
440}