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 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 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 let canonical = match fs::canonicalize(path) {
62 Ok(p) => p,
63 Err(_) => {
64 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 let _lock_file = if canonical.exists() {
143 let lock_file = fs::File::open(&canonical)?;
144 let fd = lock_file.as_raw_fd();
145 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 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 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 if result.is_err() {
186 let _ = fs::remove_file(&tmp_path);
187 }
188
189 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, };
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 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 store.write(&file_path, "initial").unwrap();
354
355 let lock_file = fs::File::open(&file_path).unwrap();
357 let fd = lock_file.as_raw_fd();
358 let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
360 assert_eq!(ret, 0, "should acquire lock");
361
362 let err = store.write(&file_path, "updated").unwrap_err();
364 assert!(
365 matches!(err, XcStringsError::FileLocked { .. }),
366 "expected FileLocked, got: {err}"
367 );
368
369 unsafe { libc::flock(fd, libc::LOCK_UN) };
372 drop(lock_file);
373
374 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, };
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 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}