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 write(&self, path: &Path, content: &str) -> Result<(), XcStringsError> {
114        let canonical = self.validate_path(path)?;
115        let dir = canonical
116            .parent()
117            .ok_or_else(|| XcStringsError::InvalidPath {
118                path: canonical.clone(),
119                reason: "no parent directory".into(),
120            })?;
121
122        // Acquire advisory lock on target file (best-effort: skip if file doesn't exist yet)
123        let _lock_file = if canonical.exists() {
124            let lock_file = fs::File::open(&canonical)?;
125            let fd = lock_file.as_raw_fd();
126            // SAFETY: flock is a POSIX syscall, fd is valid because lock_file is alive
127            let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
128            if ret != 0 {
129                let errno = std::io::Error::last_os_error();
130                if errno.kind() == std::io::ErrorKind::WouldBlock {
131                    return Err(XcStringsError::FileLocked { path: canonical });
132                }
133                // Non-blocking lock not supported (e.g. network FS) — proceed without lock
134                warn!(
135                    "advisory flock unavailable for {}: {errno} — proceeding without lock",
136                    canonical.display()
137                );
138                None
139            } else {
140                Some(lock_file)
141            }
142        } else {
143            None
144        };
145
146        let tmp_name = format!(
147            ".xcstrings-mcp-{}-{}.tmp",
148            std::process::id(),
149            SystemTime::now()
150                .duration_since(SystemTime::UNIX_EPOCH)
151                .map(|d| d.as_millis())
152                .unwrap_or(0)
153        );
154        let tmp_path = dir.join(&tmp_name);
155
156        // Write to temp file, fsync, then atomic rename
157        let result = (|| -> Result<(), XcStringsError> {
158            let mut file = fs::File::create(&tmp_path)?;
159            file.write_all(content.as_bytes())?;
160            file.sync_all()?;
161            fs::rename(&tmp_path, &canonical)?;
162            Ok(())
163        })();
164
165        // Clean up temp file on failure
166        if result.is_err() {
167            let _ = fs::remove_file(&tmp_path);
168        }
169
170        // Lock is released when _lock_file is dropped
171        result?;
172
173        info!("wrote {} bytes to {}", content.len(), canonical.display());
174        Ok(())
175    }
176
177    fn modified_time(&self, path: &Path) -> Result<SystemTime, XcStringsError> {
178        let canonical = self.validate_path(path)?;
179        let metadata = fs::metadata(&canonical)?;
180        Ok(metadata.modified()?)
181    }
182
183    fn exists(&self, path: &Path) -> bool {
184        path.exists()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use tempfile::TempDir;
192
193    #[test]
194    fn test_read_write_roundtrip() {
195        let dir = TempDir::new().unwrap();
196        let file_path = dir.path().join("test.xcstrings");
197        let store = FsFileStore::new();
198
199        let content = r#"{"sourceLanguage":"en","strings":{},"version":"1.0"}"#;
200        store.write(&file_path, content).unwrap();
201
202        let read_back = store.read(&file_path).unwrap();
203        assert_eq!(read_back, content);
204    }
205
206    #[test]
207    fn test_bom_stripping() {
208        let dir = TempDir::new().unwrap();
209        let file_path = dir.path().join("bom.xcstrings");
210
211        let content = "hello world";
212        let with_bom = format!("\u{feff}{content}");
213        std::fs::write(&file_path, with_bom.as_bytes()).unwrap();
214
215        let store = FsFileStore::new();
216        let read_back = store.read(&file_path).unwrap();
217        assert_eq!(read_back, content);
218    }
219
220    #[test]
221    fn test_file_too_large() {
222        let dir = TempDir::new().unwrap();
223        let file_path = dir.path().join("big.xcstrings");
224        std::fs::write(&file_path, "ab").unwrap();
225
226        let store = FsFileStore {
227            max_file_size: 1, // 1 byte max
228        };
229        let err = store.read(&file_path).unwrap_err();
230        assert!(
231            matches!(err, XcStringsError::FileTooLarge { .. }),
232            "expected FileTooLarge, got: {err}"
233        );
234    }
235
236    #[test]
237    fn test_path_traversal_rejected() {
238        let store = FsFileStore::new();
239        // ".." components are rejected before canonicalization
240        let result = store.validate_path(Path::new("/tmp/../etc/passwd"));
241        assert!(result.is_err(), "path traversal should be rejected");
242        let err = result.unwrap_err();
243        assert!(
244            matches!(err, XcStringsError::InvalidPath { .. }),
245            "expected InvalidPath, got: {err}"
246        );
247    }
248
249    #[test]
250    fn test_file_not_found() {
251        let dir = TempDir::new().unwrap();
252        let file_path = dir.path().join("nope.xcstrings");
253        let store = FsFileStore::new();
254
255        let err = store.read(&file_path).unwrap_err();
256        assert!(
257            matches!(err, XcStringsError::FileNotFound { .. }),
258            "expected FileNotFound, got: {err}"
259        );
260    }
261
262    #[test]
263    fn test_validate_path_no_parent() {
264        let store = FsFileStore::new();
265        let result = store.validate_path(Path::new(""));
266        assert!(result.is_err());
267    }
268
269    #[test]
270    fn test_validate_path_parent_not_exists() {
271        let store = FsFileStore::new();
272        let result = store.validate_path(Path::new("/no_such_parent_dir_xyz/file.txt"));
273        assert!(result.is_err());
274        let err = result.unwrap_err();
275        assert!(
276            matches!(err, XcStringsError::InvalidPath { .. }),
277            "expected InvalidPath, got: {err}"
278        );
279    }
280
281    #[test]
282    fn test_write_creates_file() {
283        let dir = TempDir::new().unwrap();
284        let file_path = dir.path().join("new_file.xcstrings");
285        let store = FsFileStore::new();
286
287        assert!(!file_path.exists());
288        store.write(&file_path, "content").unwrap();
289        assert!(file_path.exists());
290        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "content");
291    }
292
293    #[test]
294    fn test_modified_time() {
295        let dir = TempDir::new().unwrap();
296        let file_path = dir.path().join("timed.xcstrings");
297        let store = FsFileStore::new();
298
299        store.write(&file_path, "content").unwrap();
300        let mtime = store.modified_time(&file_path).unwrap();
301        let elapsed = SystemTime::now().duration_since(mtime).unwrap();
302        assert!(elapsed.as_secs() < 5);
303    }
304
305    #[test]
306    fn test_exists() {
307        let dir = TempDir::new().unwrap();
308        let file_path = dir.path().join("exists.xcstrings");
309        let store = FsFileStore::new();
310
311        assert!(!store.exists(&file_path));
312        store.write(&file_path, "content").unwrap();
313        assert!(store.exists(&file_path));
314    }
315
316    #[test]
317    fn test_default_impl() {
318        let store = FsFileStore::default();
319        assert!(!store.exists(Path::new("/nonexistent")));
320    }
321
322    #[test]
323    fn test_flock_blocks_concurrent_write() {
324        let dir = TempDir::new().unwrap();
325        let file_path = dir.path().join("locked.xcstrings");
326        let store = FsFileStore::new();
327
328        // Create the file first
329        store.write(&file_path, "initial").unwrap();
330
331        // Hold an exclusive lock on the file
332        let lock_file = fs::File::open(&file_path).unwrap();
333        let fd = lock_file.as_raw_fd();
334        // SAFETY: fd is valid, lock_file is alive
335        let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
336        assert_eq!(ret, 0, "should acquire lock");
337
338        // Attempt to write while locked — should fail with FileLocked
339        let err = store.write(&file_path, "updated").unwrap_err();
340        assert!(
341            matches!(err, XcStringsError::FileLocked { .. }),
342            "expected FileLocked, got: {err}"
343        );
344
345        // Release lock
346        // SAFETY: fd is valid, lock_file is alive
347        unsafe { libc::flock(fd, libc::LOCK_UN) };
348        drop(lock_file);
349
350        // Now write should succeed
351        store.write(&file_path, "updated").unwrap();
352        let content = store.read(&file_path).unwrap();
353        assert_eq!(content, "updated");
354    }
355
356    #[test]
357    fn test_atomic_write_no_orphans() {
358        let dir = TempDir::new().unwrap();
359        let file_path = dir.path().join("clean.xcstrings");
360        let store = FsFileStore::new();
361
362        store.write(&file_path, "content").unwrap();
363
364        // No .tmp files should remain
365        let tmp_files: Vec<_> = std::fs::read_dir(dir.path())
366            .unwrap()
367            .filter_map(|e| e.ok())
368            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
369            .collect();
370        assert!(
371            tmp_files.is_empty(),
372            "orphan tmp files found: {tmp_files:?}"
373        );
374    }
375}