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 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 let _lock_file = if canonical.exists() {
124 let lock_file = fs::File::open(&canonical)?;
125 let fd = lock_file.as_raw_fd();
126 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 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 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 if result.is_err() {
167 let _ = fs::remove_file(&tmp_path);
168 }
169
170 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, };
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 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 store.write(&file_path, "initial").unwrap();
330
331 let lock_file = fs::File::open(&file_path).unwrap();
333 let fd = lock_file.as_raw_fd();
334 let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
336 assert_eq!(ret, 0, "should acquire lock");
337
338 let err = store.write(&file_path, "updated").unwrap_err();
340 assert!(
341 matches!(err, XcStringsError::FileLocked { .. }),
342 "expected FileLocked, got: {err}"
343 );
344
345 unsafe { libc::flock(fd, libc::LOCK_UN) };
348 drop(lock_file);
349
350 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 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}