mcp_execution_files/filesystem.rs
1//! In-memory filesystem and export functionality.
2//!
3//! Provides an in-memory filesystem for MCP tool definitions with
4//! high-performance export to real filesystem.
5//!
6//! # Core Features
7//!
8//! - **In-memory storage**: Files stored in `HashMap` for O(1) lookup
9//! - **Filesystem export**: Sequential and parallel export modes
10//! - **Atomic writes**: Optional atomic file operations
11//! - **Thread-safe**: All types are `Send + Sync`
12//!
13//! # Atomicity
14//!
15//! [`FileSystem::export_to_filesystem`] stages the entire export in a sibling
16//! temporary directory and only publishes it by renaming that directory into
17//! place once every file has been written successfully. A process interrupted
18//! mid-export leaves the previous export (or nothing, on a first export)
19//! untouched at the target path — never a partially written tree.
20//!
21//! # Performance Optimizations
22//!
23//! 1. **Directory Pre-creation**: Creates all directories first in single pass
24//! 2. **Parallel Writes**: Uses rayon for parallel file writing (opt-in)
25//! 3. **Atomic Operations**: Writes to temp file then renames
26//! 4. **Minimal Allocations**: Reuses path buffers, caches canonicalized base
27//!
28//! # Examples
29//!
30//! ## Basic usage
31//!
32//! ```
33//! use mcp_execution_files::FileSystem;
34//!
35//! let mut fs = FileSystem::new();
36//! fs.add_file("/mcp-tools/test.ts", "export const VERSION = '1.0';").unwrap();
37//!
38//! let content = fs.read_file("/mcp-tools/test.ts").unwrap();
39//! assert_eq!(content, "export const VERSION = '1.0';");
40//! ```
41//!
42//! ## Export to filesystem
43//!
44//! ```
45//! use mcp_execution_files::FilesBuilder;
46//! # use tempfile::TempDir;
47//!
48//! # let temp_dir = TempDir::new().unwrap();
49//! # let output_dir = temp_dir.path();
50//! let fs = FilesBuilder::new()
51//! .add_file("/tools/create.ts", "export function create() {}")
52//! .add_file("/tools/update.ts", "export function update() {}")
53//! .build()
54//! .unwrap();
55//!
56//! // Export to filesystem
57//! fs.export_to_filesystem(output_dir).unwrap();
58//!
59//! assert!(output_dir.join("tools/create.ts").exists());
60//! # Ok::<(), Box<dyn std::error::Error>>(())
61//! ```
62
63use crate::types::{FileEntry, FilePath, FilesError, Result};
64use std::collections::{HashMap, HashSet};
65use std::fs;
66use std::io::Write;
67use std::path::{Path, PathBuf};
68use std::sync::atomic::{AtomicU64, Ordering};
69use std::time::{Duration, SystemTime, UNIX_EPOCH};
70use tempfile::TempDir;
71
72/// Minimum age (by mtime) a staging/stale sibling must have before
73/// [`FileSystem::sweep_stale_artifacts`] will remove it.
74///
75/// A genuinely concurrent sibling export's staging and displaced
76/// directories are kept younger than this: `staging` is freshly created via
77/// `tempfile` for every export, and [`FileSystem::displace_existing_target`]
78/// refreshes `target`'s mtime via [`FileSystem::touch_dir`] immediately
79/// *before* renaming it aside to `displaced` — a rename preserves the
80/// source's mtime, so `displaced` carries that fresh timestamp from the
81/// instant it exists, rather than inheriting `target`'s (typically much
82/// older) original mtime and being immediately sweep-eligible. All of the
83/// work between
84/// creating/refreshing one of these directories and either publishing or
85/// rolling it back happens within a single
86/// `export_to_filesystem_with_options` call, which completes in well under
87/// this window even for large tool sets — assuming that call keeps adding
88/// or removing immediate children of `staging` as it writes, since that is
89/// what keeps its mtime fresh on the filesystems this crate supports.
90/// Gating on age means the sweep can only ever reclaim directories that are
91/// old enough to be leftovers from a process that was killed (e.g.
92/// `SIGKILL`) before it could clean up after itself — never a live
93/// sibling's in-flight artifacts. This is what closes the data-loss race
94/// described in issue #169: previously the sweep matched purely on name, so
95/// a concurrent export of the same target could delete another in-flight
96/// export's staging directory, and — if the timing landed between
97/// `swap_into_place`'s two renames — the displaced original too, defeating
98/// the rollback and permanently losing the target.
99const STALE_ARTIFACT_MIN_AGE: Duration = Duration::from_mins(5);
100
101/// An in-memory virtual filesystem for MCP tool definitions.
102///
103/// `FileSystem` provides a read-only filesystem structure that stores generated
104/// TypeScript files in memory. Files are organized in a hierarchical structure
105/// like `/mcp-tools/servers/<server-id>/...`.
106///
107/// # Thread Safety
108///
109/// This type is `Send` and `Sync`, making it safe to use across threads.
110///
111/// # Examples
112///
113/// ```
114/// use mcp_execution_files::FileSystem;
115///
116/// let mut vfs = FileSystem::new();
117/// vfs.add_file("/mcp-tools/manifest.json", "{}").unwrap();
118///
119/// assert!(vfs.exists("/mcp-tools/manifest.json"));
120/// assert_eq!(vfs.file_count(), 1);
121/// ```
122#[derive(Debug, Clone)]
123pub struct FileSystem {
124 files: HashMap<FilePath, FileEntry>,
125}
126
127impl FileSystem {
128 /// Creates a new empty virtual filesystem.
129 ///
130 /// # Examples
131 ///
132 /// ```
133 /// use mcp_execution_files::FileSystem;
134 ///
135 /// let vfs = FileSystem::new();
136 /// assert_eq!(vfs.file_count(), 0);
137 /// ```
138 #[must_use]
139 pub fn new() -> Self {
140 Self {
141 files: HashMap::new(),
142 }
143 }
144
145 /// Adds a file to the virtual filesystem.
146 ///
147 /// If a file already exists at the path, it will be replaced.
148 ///
149 /// # Errors
150 ///
151 /// Returns an error if the path is invalid (not absolute, contains '..', etc.).
152 ///
153 /// # Examples
154 ///
155 /// ```
156 /// use mcp_execution_files::FileSystem;
157 ///
158 /// let mut vfs = FileSystem::new();
159 /// vfs.add_file("/mcp-tools/test.ts", "console.log('hello');").unwrap();
160 ///
161 /// assert!(vfs.exists("/mcp-tools/test.ts"));
162 /// # Ok::<(), mcp_execution_files::FilesError>(())
163 /// ```
164 pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) -> Result<()> {
165 let vfs_path = FilePath::new(path)?;
166 let file = FileEntry::new(content);
167 self.files.insert(vfs_path, file);
168 Ok(())
169 }
170
171 /// Reads the content of a file.
172 ///
173 /// # Errors
174 ///
175 /// Returns `FilesError::FileNotFound` if the file does not exist.
176 /// Returns `FilesError::InvalidPath` if the path is invalid.
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use mcp_execution_files::FileSystem;
182 ///
183 /// let mut vfs = FileSystem::new();
184 /// vfs.add_file("/test.ts", "export {}").unwrap();
185 ///
186 /// let content = vfs.read_file("/test.ts").unwrap();
187 /// assert_eq!(content, "export {}");
188 /// # Ok::<(), mcp_execution_files::FilesError>(())
189 /// ```
190 pub fn read_file(&self, path: impl AsRef<Path>) -> Result<&str> {
191 let vfs_path = FilePath::new(path)?;
192 self.files
193 .get(&vfs_path)
194 .map(FileEntry::content)
195 .ok_or_else(|| FilesError::FileNotFound {
196 path: vfs_path.as_str().to_string(),
197 })
198 }
199
200 /// Checks if a file exists at the given path.
201 ///
202 /// Returns `false` if the path is invalid.
203 ///
204 /// # Examples
205 ///
206 /// ```
207 /// use mcp_execution_files::FileSystem;
208 ///
209 /// let mut vfs = FileSystem::new();
210 /// vfs.add_file("/exists.ts", "").unwrap();
211 ///
212 /// assert!(vfs.exists("/exists.ts"));
213 /// assert!(!vfs.exists("/missing.ts"));
214 /// ```
215 #[must_use]
216 pub fn exists(&self, path: impl AsRef<Path>) -> bool {
217 FilePath::new(path)
218 .ok()
219 .and_then(|p| self.files.get(&p))
220 .is_some()
221 }
222
223 /// Lists all files and directories in a directory.
224 ///
225 /// Returns an empty vector if the directory is empty or does not exist.
226 ///
227 /// # Errors
228 ///
229 /// Returns `FilesError::InvalidPath` if the path is invalid.
230 /// Returns `FilesError::NotADirectory` if the path points to a file.
231 ///
232 /// # Examples
233 ///
234 /// ```
235 /// use mcp_execution_files::FileSystem;
236 ///
237 /// let mut vfs = FileSystem::new();
238 /// vfs.add_file("/mcp-tools/servers/test1.ts", "").unwrap();
239 /// vfs.add_file("/mcp-tools/servers/test2.ts", "").unwrap();
240 ///
241 /// let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
242 /// assert_eq!(entries.len(), 2);
243 /// # Ok::<(), mcp_execution_files::FilesError>(())
244 /// ```
245 pub fn list_dir(&self, path: impl AsRef<Path>) -> Result<Vec<FilePath>> {
246 let vfs_path = FilePath::new(path)?;
247 let path_str = vfs_path.as_str();
248
249 // Check if the path itself is a file
250 if self.files.contains_key(&vfs_path) {
251 return Err(FilesError::NotADirectory {
252 path: path_str.to_string(),
253 });
254 }
255
256 // Collect all direct children
257 let mut children = Vec::new();
258 let normalized_dir = if path_str.ends_with('/') {
259 path_str.to_string()
260 } else {
261 format!("{path_str}/")
262 };
263
264 for file_path in self.files.keys() {
265 let file_str = file_path.as_str();
266
267 // Check if this file is under the directory
268 if file_str.starts_with(&normalized_dir) {
269 let relative = &file_str[normalized_dir.len()..];
270
271 // Only include direct children (no subdirectories)
272 if !relative.contains('/') && !relative.is_empty() {
273 children.push(file_path.clone());
274 } else if let Some(idx) = relative.find('/') {
275 // This is a subdirectory, add the directory path
276 let subdir = format!("{}{}", normalized_dir, &relative[..idx]);
277 if let Ok(subdir_path) = FilePath::new(subdir)
278 && !children.contains(&subdir_path)
279 {
280 children.push(subdir_path);
281 }
282 }
283 }
284 }
285
286 children.sort_by(|a, b| a.as_str().cmp(b.as_str()));
287 Ok(children)
288 }
289
290 /// Returns the total number of files in the VFS.
291 ///
292 /// # Examples
293 ///
294 /// ```
295 /// use mcp_execution_files::FileSystem;
296 ///
297 /// let mut vfs = FileSystem::new();
298 /// assert_eq!(vfs.file_count(), 0);
299 ///
300 /// vfs.add_file("/test1.ts", "").unwrap();
301 /// vfs.add_file("/test2.ts", "").unwrap();
302 /// assert_eq!(vfs.file_count(), 2);
303 /// ```
304 #[must_use]
305 pub fn file_count(&self) -> usize {
306 self.files.len()
307 }
308
309 /// Returns all file paths in the VFS.
310 ///
311 /// The paths are returned in sorted order.
312 ///
313 /// # Examples
314 ///
315 /// ```
316 /// use mcp_execution_files::FileSystem;
317 ///
318 /// let mut vfs = FileSystem::new();
319 /// vfs.add_file("/a.ts", "").unwrap();
320 /// vfs.add_file("/b.ts", "").unwrap();
321 ///
322 /// let paths = vfs.all_paths();
323 /// assert_eq!(paths.len(), 2);
324 /// ```
325 #[must_use]
326 pub fn all_paths(&self) -> Vec<&FilePath> {
327 let mut paths: Vec<_> = self.files.keys().collect();
328 paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
329 paths
330 }
331
332 /// Returns an iterator over all files in the VFS.
333 ///
334 /// Each item is a tuple of `(&FilePath, &FileEntry)`.
335 ///
336 /// # Examples
337 ///
338 /// ```
339 /// use mcp_execution_files::FileSystem;
340 ///
341 /// let mut vfs = FileSystem::new();
342 /// vfs.add_file("/a.ts", "content a").unwrap();
343 /// vfs.add_file("/b.ts", "content b").unwrap();
344 ///
345 /// let files: Vec<_> = vfs.files().collect();
346 /// assert_eq!(files.len(), 2);
347 /// ```
348 pub fn files(&self) -> impl Iterator<Item = (&FilePath, &FileEntry)> {
349 self.files.iter()
350 }
351
352 /// Removes all files from the VFS.
353 ///
354 /// # Examples
355 ///
356 /// ```
357 /// use mcp_execution_files::FileSystem;
358 ///
359 /// let mut vfs = FileSystem::new();
360 /// vfs.add_file("/test.ts", "").unwrap();
361 /// assert_eq!(vfs.file_count(), 1);
362 ///
363 /// vfs.clear();
364 /// assert_eq!(vfs.file_count(), 0);
365 /// ```
366 pub fn clear(&mut self) {
367 self.files.clear();
368 }
369
370 /// Exports VFS contents to real filesystem.
371 ///
372 /// This is a high-performance implementation optimized for the progressive
373 /// loading pattern. It pre-creates all directories and writes files sequentially.
374 ///
375 /// # Performance
376 ///
377 /// Target: <50ms for 30 files (GitHub server typical case)
378 ///
379 /// Optimizations:
380 /// - Single pass directory creation
381 /// - Cached canonicalized base path
382 /// - Minimal allocations
383 ///
384 /// # Errors
385 ///
386 /// Returns error if:
387 /// - Base path doesn't exist or isn't a directory
388 /// - Permission denied
389 /// - I/O error during write
390 ///
391 /// # Examples
392 ///
393 /// ```
394 /// use mcp_execution_files::FilesBuilder;
395 /// # use tempfile::TempDir;
396 ///
397 /// # let temp = TempDir::new().unwrap();
398 /// # let base = temp.path();
399 /// let vfs = FilesBuilder::new()
400 /// .add_file("/manifest.json", "{}")
401 /// .build()
402 /// .unwrap();
403 ///
404 /// vfs.export_to_filesystem(base).unwrap();
405 /// assert!(base.join("manifest.json").exists());
406 /// # Ok::<(), Box<dyn std::error::Error>>(())
407 /// ```
408 pub fn export_to_filesystem(&self, base_path: impl AsRef<Path>) -> Result<()> {
409 self.export_to_filesystem_with_options(base_path, &ExportOptions::default())
410 }
411
412 /// Exports VFS contents with custom options.
413 ///
414 /// The export is staged in a temporary sibling directory next to
415 /// `base_path` and published atomically: only once every file has been
416 /// written to the staging directory is it renamed into place. If the
417 /// process is interrupted at any point before publishing, `base_path` is
418 /// left exactly as it was — either untouched or, if it did not exist yet,
419 /// still absent. See the [module-level docs](self) for details.
420 ///
421 /// Concurrent exports of the *same* `base_path` from different processes
422 /// are not locked against each other and can still race on the final
423 /// swap — one export's result may be silently overwritten by another's
424 /// (a lost update). The age-gated sweep of orphaned staging/displaced
425 /// directories (`sweep_stale_artifacts`) guarantees neither export's
426 /// staging/displaced directories can be deleted out from under it before
427 /// its own rollback path runs, so this can no longer result in the
428 /// target, its staging directory, and its displaced backup all being
429 /// lost at once. Callers that need stronger-than-last-write-wins
430 /// semantics for the same target should serialize writes themselves
431 /// (e.g. a per-target lock) — see `mcp-execution-server`'s
432 /// `introspector_for` for the pattern this project already uses for a
433 /// similar problem. Concurrent exports of different targets sharing a
434 /// parent directory are safe.
435 ///
436 /// # Errors
437 ///
438 /// Returns an error if:
439 /// - The parent directory of `base_path` does not exist
440 /// - The staging directory cannot be created or canonicalized
441 /// - I/O operations fail during directory creation, file writing, or the
442 /// final publish step
443 ///
444 /// # Examples
445 ///
446 /// ```
447 /// use mcp_execution_files::{FilesBuilder, ExportOptions};
448 /// # use tempfile::TempDir;
449 ///
450 /// # let temp = TempDir::new().unwrap();
451 /// # let base = temp.path();
452 /// let vfs = FilesBuilder::new()
453 /// .add_file("/test.ts", "export {}")
454 /// .build()
455 /// .unwrap();
456 ///
457 /// let options = ExportOptions::default().with_atomic_writes(false);
458 /// vfs.export_to_filesystem_with_options(base, &options).unwrap();
459 /// # Ok::<(), Box<dyn std::error::Error>>(())
460 /// ```
461 pub fn export_to_filesystem_with_options(
462 &self,
463 base_path: impl AsRef<Path>,
464 options: &ExportOptions,
465 ) -> Result<()> {
466 let target = base_path.as_ref();
467
468 let parent = target.parent().ok_or_else(|| FilesError::InvalidPath {
469 path: format!("Target path has no parent directory: {}", target.display()),
470 })?;
471
472 if !parent.exists() {
473 return Err(FilesError::FileNotFound {
474 path: parent.display().to_string(),
475 });
476 }
477
478 // Best-effort cleanup of orphaned staging/displaced directories left
479 // behind by a previous run that was killed (e.g. `SIGKILL`) before it
480 // could clean up after itself — `TempDir::drop` never runs in that
481 // case. This bounds the leak to at most one generation between
482 // crashes rather than letting full tree copies accumulate forever.
483 // Scoped to this `target`'s own name so it never touches a sibling
484 // export's in-flight staging directory (e.g. two `generate` runs for
485 // different servers publishing into the same `~/.claude/servers/`).
486 Self::sweep_stale_artifacts(parent, target);
487
488 // Stage the export in a sibling directory on the same filesystem so the
489 // final publish step below is a single directory rename rather than a
490 // sequence of individually-visible file writes. The prefix is scoped to
491 // `target`'s own name for the same reason as the sweep above.
492 let staging = tempfile::Builder::new()
493 .prefix(&Self::staging_prefix(target))
494 .tempdir_in(parent)
495 .map_err(|e| FilesError::IoError {
496 path: parent.display().to_string(),
497 source: e,
498 })?;
499
500 let canonical_staging =
501 staging
502 .path()
503 .canonicalize()
504 .map_err(|e| FilesError::InvalidPath {
505 path: format!("Failed to canonicalize {}: {}", staging.path().display(), e),
506 })?;
507
508 // Phase 1: Collect all unique directories
509 let dirs = self.collect_directories(&canonical_staging);
510
511 // Phase 2: Create all directories in one pass
512 Self::create_directories(&dirs)?;
513
514 // Phase 3: Write all files into the staging directory. If this fails,
515 // `staging` is dropped here and its `Drop` impl removes the partial
516 // tree — `target` is never touched.
517 self.write_files(&canonical_staging, options)?;
518
519 // Every file landed successfully; publish by swapping the staged
520 // directory into place.
521 Self::publish_staged_export(staging, target)
522 }
523
524 /// Exports VFS contents using parallel writes (requires 'parallel' feature).
525 ///
526 /// Faster for large numbers of files (>50), but may not preserve write order.
527 ///
528 /// # Errors
529 ///
530 /// Returns error if:
531 /// - Base path doesn't exist or isn't a directory
532 /// - Permission denied during directory creation or file write
533 /// - I/O error during parallel write operations
534 ///
535 /// # Examples
536 ///
537 /// ```
538 /// use mcp_execution_files::FilesBuilder;
539 /// # use tempfile::TempDir;
540 ///
541 /// # let temp = TempDir::new().unwrap();
542 /// # let base = temp.path();
543 /// let vfs = FilesBuilder::new()
544 /// .add_file("/tool1.ts", "export {}")
545 /// .add_file("/tool2.ts", "export {}")
546 /// .build()
547 /// .unwrap();
548 ///
549 /// #[cfg(feature = "parallel")]
550 /// vfs.export_to_filesystem_parallel(base).unwrap();
551 /// # Ok::<(), Box<dyn std::error::Error>>(())
552 /// ```
553 // TODO(critic): not directory-atomic — needs staging treatment before any
554 // production caller is wired up (currently unused outside this crate's
555 // own tests/benches, so the interrupted-export bug fixed in
556 // `export_to_filesystem` does not apply here yet).
557 #[cfg(feature = "parallel")]
558 pub fn export_to_filesystem_parallel(&self, base_path: impl AsRef<Path>) -> Result<()> {
559 use rayon::prelude::*;
560
561 let base = base_path.as_ref();
562 let canonical_base = base.canonicalize().map_err(|e| FilesError::InvalidPath {
563 path: format!("Failed to canonicalize {}: {}", base.display(), e),
564 })?;
565
566 // Phase 1: Collect and create directories (must be sequential)
567 let dirs = self.collect_directories(&canonical_base);
568 Self::create_directories(&dirs)?;
569
570 // Phase 2: Write files in parallel
571 let files: Vec<_> = self.files().collect();
572 let options = ExportOptions::default();
573
574 files
575 .par_iter()
576 .try_for_each(|(vfs_path, file)| -> Result<()> {
577 let disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), &canonical_base);
578 write_file_atomic(&disk_path, file.content(), options.atomic)
579 })?;
580
581 Ok(())
582 }
583
584 /// Collects all unique directory paths needed for export.
585 ///
586 /// This is done in a single pass to minimize allocations.
587 fn collect_directories(&self, base: &Path) -> HashSet<PathBuf> {
588 let mut dirs = HashSet::new();
589
590 for (vfs_path, _) in self.files() {
591 let disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), base);
592
593 // Add all parent directories
594 if let Some(parent) = disk_path.parent() {
595 // Insert parent and all ancestors
596 let mut current = parent;
597 while current != base && dirs.insert(current.to_path_buf()) {
598 if let Some(p) = current.parent() {
599 current = p;
600 } else {
601 break;
602 }
603 }
604 }
605 }
606
607 dirs
608 }
609
610 /// Creates all directories in one pass.
611 ///
612 /// Uses `fs::create_dir_all` which is efficient for creating directory trees.
613 fn create_directories(dirs: &HashSet<PathBuf>) -> Result<()> {
614 for dir in dirs {
615 fs::create_dir_all(dir).map_err(|e| FilesError::InvalidPath {
616 path: format!("Failed to create directory {}: {}", dir.display(), e),
617 })?;
618 }
619 Ok(())
620 }
621
622 /// Writes all files into the staging directory ahead of publishing.
623 fn write_files(&self, staging_base: &Path, options: &ExportOptions) -> Result<()> {
624 for (vfs_path, file) in self.files() {
625 let staging_disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), staging_base);
626 write_file_atomic(&staging_disk_path, file.content(), options.atomic)?;
627 }
628 Ok(())
629 }
630
631 /// Publishes a fully staged export by atomically swapping it into `target`.
632 ///
633 /// If the swap itself fails, the staging directory is removed so no
634 /// orphaned staging directories (see [`Self::staging_prefix`]) accumulate
635 /// next to `target`.
636 fn publish_staged_export(staging: TempDir, target: &Path) -> Result<()> {
637 // Disown the `TempDir` guard: ownership of cleanup now belongs to
638 // `swap_into_place` (on failure) or to `target` itself (on success).
639 let staging_path = staging.keep();
640
641 if let Err(err) = Self::swap_into_place(&staging_path, target) {
642 let _ = fs::remove_dir_all(&staging_path);
643 return Err(err);
644 }
645
646 Ok(())
647 }
648
649 /// Atomically replaces `target` with the directory at `staging_path`.
650 ///
651 /// A directory rename cannot replace a non-empty destination on any
652 /// platform this crate supports, so an existing `target` is first moved
653 /// aside to a unique sibling path, the staged directory is renamed into
654 /// `target`, and only then is the displaced directory removed. If this
655 /// function *returns* an error, the second rename failed and the original
656 /// directory has been moved back, so `target` is never left missing as
657 /// observed by a caller of this function.
658 ///
659 /// This guarantee does not extend to a process that is killed (e.g.
660 /// `SIGKILL`) between the two renames: in that narrow window `target` is
661 /// transiently absent and the previous export sits at a `.stale-*`
662 /// sibling until [`FileSystem::sweep_stale_artifacts`] reclaims it on a
663 /// later export. That failure mode is louder (a missing directory) than
664 /// the silent broken-import bug this fix replaces.
665 fn swap_into_place(staging_path: &Path, target: &Path) -> Result<()> {
666 if !target.exists() {
667 return fs::rename(staging_path, target).map_err(|e| FilesError::IoError {
668 path: target.display().to_string(),
669 source: e,
670 });
671 }
672
673 let parent = target.parent().ok_or_else(|| FilesError::InvalidPath {
674 path: format!("Target path has no parent directory: {}", target.display()),
675 })?;
676 let displaced = Self::displace_existing_target(target, parent)?;
677
678 if let Err(e) = fs::rename(staging_path, target) {
679 // Roll back so `target` is never left missing.
680 let _ = fs::rename(&displaced, target);
681 return Err(FilesError::IoError {
682 path: target.display().to_string(),
683 source: e,
684 });
685 }
686
687 let _ = fs::remove_dir_all(&displaced);
688 Ok(())
689 }
690
691 /// Moves an existing `target` aside to a unique sibling path in `parent`
692 /// so the staged directory can be renamed into `target`'s place,
693 /// returning the displaced path.
694 ///
695 /// A directory rename does not reset the renamed directory's own mtime,
696 /// so without an explicit refresh `displaced` would inherit `target`'s
697 /// original mtime — almost always well past [`STALE_ARTIFACT_MIN_AGE`],
698 /// since a target being regenerated has typically existed since a prior
699 /// export. That would make it immediately eligible for a concurrent
700 /// sibling's [`Self::sweep_stale_artifacts`] pass, defeating the age
701 /// gate for exactly the directory [`Self::swap_into_place`]'s own
702 /// rollback depends on (issue #169). [`Self::touch_dir`] refreshes
703 /// `target`'s mtime *before* the rename rather than `displaced`'s
704 /// *after* it, since a rename preserves the source's mtime — so
705 /// `displaced` carries a fresh mtime from the instant it exists, with
706 /// no window in which it is stale even momentarily.
707 fn displace_existing_target(target: &Path, parent: &Path) -> Result<PathBuf> {
708 let displaced = parent.join(Self::unique_sibling_name(target));
709
710 Self::touch_dir(target);
711
712 fs::rename(target, &displaced).map_err(|e| FilesError::IoError {
713 path: target.display().to_string(),
714 source: e,
715 })?;
716
717 Ok(displaced)
718 }
719
720 /// Best-effort refresh of `dir`'s modification time.
721 ///
722 /// Deliberately avoids opening `dir` as a `File` to call
723 /// [`std::fs::File::set_times`], since opening a directory for write
724 /// access is not portable across every platform this crate supports.
725 /// Instead, creating and immediately removing a marker file inside
726 /// `dir` achieves the same effect portably: any filesystem that tracks
727 /// directory mtimes bumps them when an immediate child is added or
728 /// removed. Best-effort like [`Self::sweep_stale_artifacts`]: failure
729 /// (e.g. a read-only directory) is silently ignored rather than failing
730 /// the export.
731 fn touch_dir(dir: &Path) {
732 let marker = dir.join(format!(".mtime-touch-{}", std::process::id()));
733 if fs::File::create(&marker).is_ok() {
734 let _ = fs::remove_file(&marker);
735 }
736 }
737
738 /// Returns `target`'s file name for use as a namespacing stem in sibling
739 /// artifact names, so that concurrent exports of *different* targets in
740 /// the same parent directory (e.g. two `generate` runs publishing into
741 /// the same `~/.claude/servers/`) never collide or interfere with one
742 /// another's staging/displaced directories.
743 fn target_stem(target: &Path) -> &str {
744 target
745 .file_name()
746 .and_then(|n| n.to_str())
747 .unwrap_or("export")
748 }
749
750 /// Returns the `tempfile` prefix used for `target`'s staging directory.
751 fn staging_prefix(target: &Path) -> String {
752 format!(".{}.staging-", Self::target_stem(target))
753 }
754
755 /// Generates a unique sibling file name used to temporarily displace
756 /// `target` during the atomic swap.
757 fn unique_sibling_name(target: &Path) -> PathBuf {
758 static COUNTER: AtomicU64 = AtomicU64::new(0);
759
760 let stem = Self::target_stem(target);
761 let nanos = SystemTime::now()
762 .duration_since(UNIX_EPOCH)
763 .map_or(0, |d| d.as_nanos());
764 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
765
766 PathBuf::from(format!(
767 ".{stem}.stale-{}-{nanos}-{seq}",
768 std::process::id()
769 ))
770 }
771
772 /// Removes orphaned staging/displaced directories left next to `target`
773 /// by a previous export of the *same* `target` that was killed before it
774 /// could clean up after itself (`TempDir::drop` and the rollback in
775 /// [`Self::swap_into_place`] both require the process to still be
776 /// running). Scoped to `target`'s own name (see [`Self::target_stem`]) so
777 /// it never touches a sibling export's in-flight artifacts.
778 ///
779 /// Best-effort: this is a hygiene pass, not part of the export's
780 /// correctness, so any I/O error while scanning or removing an entry is
781 /// silently ignored rather than failing the export.
782 ///
783 /// Only removes entries at least [`STALE_ARTIFACT_MIN_AGE`] old (by
784 /// mtime) — see that constant's doc for why this age gate is what makes
785 /// the sweep safe to run against a concurrently in-flight sibling
786 /// export.
787 fn sweep_stale_artifacts(parent: &Path, target: &Path) {
788 Self::sweep_stale_artifacts_older_than(parent, target, STALE_ARTIFACT_MIN_AGE);
789 }
790
791 /// Inner implementation of [`Self::sweep_stale_artifacts`], parameterized
792 /// over the age threshold so tests can exercise the sweep logic without
793 /// backdating real file mtimes (pass [`Duration::ZERO`] to sweep
794 /// unconditionally).
795 fn sweep_stale_artifacts_older_than(parent: &Path, target: &Path, min_age: Duration) {
796 let stem = Self::target_stem(target);
797 let staging_prefix = Self::staging_prefix(target);
798 let stale_prefix = format!(".{stem}.stale-");
799
800 let Ok(entries) = fs::read_dir(parent) else {
801 return;
802 };
803
804 for entry in entries.flatten() {
805 let is_orphan = entry.file_name().to_str().is_some_and(|name| {
806 name.starts_with(&staging_prefix) || name.starts_with(&stale_prefix)
807 });
808 if !is_orphan {
809 continue;
810 }
811
812 // Too young to be a crash orphan — could be a live sibling
813 // export's in-flight artifacts; leave it for a later sweep once
814 // it ages past `min_age`. Metadata errors (entry vanished,
815 // permission issue) are treated the same way — skip rather than
816 // guess, since this is a best-effort hygiene pass, not part of
817 // export correctness.
818 let old_enough = entry
819 .metadata()
820 .and_then(|m| m.modified())
821 .is_ok_and(|modified| {
822 SystemTime::now()
823 .duration_since(modified)
824 .is_ok_and(|age| age >= min_age)
825 });
826
827 if old_enough {
828 let _ = fs::remove_dir_all(entry.path());
829 }
830 }
831 }
832
833 /// Converts VFS path to disk path.
834 ///
835 /// Strips leading '/' and joins with base path.
836 ///
837 /// # Panics
838 ///
839 /// Panics if path contains `..` (path traversal attempt).
840 /// This is defense-in-depth since `FilePath::new()` also validates.
841 fn vfs_to_disk_path(vfs_path: &str, base: &Path) -> PathBuf {
842 // Strip leading '/' from VFS path
843 let relative = vfs_path.strip_prefix('/').unwrap_or(vfs_path);
844
845 // Defense-in-depth: reject path traversal attempts
846 // Primary validation is in FilePath::new(), this is a safety net
847 assert!(
848 !relative.contains(".."),
849 "SECURITY: Path traversal attempt detected in VFS path: {vfs_path}"
850 );
851
852 // Convert forward slashes to platform-specific separators
853 let relative_path = if cfg!(target_os = "windows") {
854 PathBuf::from(relative.replace('/', "\\"))
855 } else {
856 PathBuf::from(relative)
857 };
858
859 base.join(relative_path)
860 }
861}
862
863impl Default for FileSystem {
864 fn default() -> Self {
865 Self::new()
866 }
867}
868
869/// Options for filesystem export operations.
870///
871/// # Examples
872///
873/// ```
874/// use mcp_execution_files::ExportOptions;
875///
876/// let options = ExportOptions::default().with_atomic_writes(true);
877/// ```
878#[derive(Debug, Clone)]
879pub struct ExportOptions {
880 /// Use atomic writes (write to temp file, then rename)
881 pub atomic: bool,
882}
883
884impl ExportOptions {
885 /// Creates new export options with defaults.
886 ///
887 /// Defaults:
888 /// - atomic: true (safer)
889 #[must_use]
890 pub const fn new() -> Self {
891 Self { atomic: true }
892 }
893
894 /// Sets whether to use atomic writes.
895 #[must_use]
896 pub const fn with_atomic_writes(mut self, atomic: bool) -> Self {
897 self.atomic = atomic;
898 self
899 }
900}
901
902impl Default for ExportOptions {
903 fn default() -> Self {
904 Self::new()
905 }
906}
907
908/// Writes file content to disk.
909///
910/// If `atomic` is `true`, writes to a temp file then renames it into place.
911/// Otherwise, writes directly.
912fn write_file_atomic(path: &Path, content: &str, atomic: bool) -> Result<()> {
913 if atomic {
914 // Atomic write: temp file + rename
915 let temp_path = path.with_added_extension("tmp");
916
917 // Write to temp file
918 let mut file = fs::File::create(&temp_path).map_err(|e| FilesError::InvalidPath {
919 path: format!("Failed to create temp file {}: {}", temp_path.display(), e),
920 })?;
921
922 file.write_all(content.as_bytes())
923 .map_err(|e| FilesError::InvalidPath {
924 path: format!("Failed to write to {}: {}", temp_path.display(), e),
925 })?;
926
927 file.sync_all().map_err(|e| FilesError::InvalidPath {
928 path: format!("Failed to sync {}: {}", temp_path.display(), e),
929 })?;
930
931 // Rename to final location
932 fs::rename(&temp_path, path).map_err(|e| FilesError::InvalidPath {
933 path: format!(
934 "Failed to rename {} to {}: {}",
935 temp_path.display(),
936 path.display(),
937 e
938 ),
939 })?;
940 } else {
941 // Direct write (faster, but not atomic)
942 fs::write(path, content).map_err(|e| FilesError::InvalidPath {
943 path: format!("Failed to write {}: {}", path.display(), e),
944 })?;
945 }
946
947 Ok(())
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use crate::FilesBuilder;
954 use tempfile::TempDir;
955
956 // FileSystem core tests
957 #[test]
958 fn test_vfs_new() {
959 let vfs = FileSystem::new();
960 assert_eq!(vfs.file_count(), 0);
961 }
962
963 #[test]
964 fn test_vfs_default() {
965 let vfs = FileSystem::default();
966 assert_eq!(vfs.file_count(), 0);
967 }
968
969 #[test]
970 fn test_add_file() {
971 let mut vfs = FileSystem::new();
972 vfs.add_file("/test.ts", "content").unwrap();
973 assert_eq!(vfs.file_count(), 1);
974 }
975
976 #[test]
977 fn test_add_file_invalid_path() {
978 let mut vfs = FileSystem::new();
979 let result = vfs.add_file("relative/path", "content");
980 assert!(result.is_err());
981 }
982
983 #[test]
984 fn test_read_file() {
985 let mut vfs = FileSystem::new();
986 vfs.add_file("/test.ts", "hello world").unwrap();
987
988 let content = vfs.read_file("/test.ts").unwrap();
989 assert_eq!(content, "hello world");
990 }
991
992 #[test]
993 fn test_read_file_not_found() {
994 let vfs = FileSystem::new();
995 let result = vfs.read_file("/missing.ts");
996 assert!(result.is_err());
997 assert!(result.unwrap_err().is_not_found());
998 }
999
1000 #[test]
1001 fn test_exists() {
1002 let mut vfs = FileSystem::new();
1003 vfs.add_file("/exists.ts", "").unwrap();
1004
1005 assert!(vfs.exists("/exists.ts"));
1006 assert!(!vfs.exists("/missing.ts"));
1007 }
1008
1009 #[test]
1010 fn test_exists_invalid_path() {
1011 let vfs = FileSystem::new();
1012 assert!(!vfs.exists("relative/path"));
1013 }
1014
1015 #[test]
1016 fn test_list_dir() {
1017 let mut vfs = FileSystem::new();
1018 vfs.add_file("/mcp-tools/servers/test1.ts", "").unwrap();
1019 vfs.add_file("/mcp-tools/servers/test2.ts", "").unwrap();
1020
1021 let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
1022 assert_eq!(entries.len(), 2);
1023 }
1024
1025 #[test]
1026 fn test_list_dir_empty() {
1027 let vfs = FileSystem::new();
1028 let entries = vfs.list_dir("/empty").unwrap();
1029 assert_eq!(entries.len(), 0);
1030 }
1031
1032 #[test]
1033 fn test_list_dir_not_a_directory() {
1034 let mut vfs = FileSystem::new();
1035 vfs.add_file("/file.ts", "").unwrap();
1036
1037 let result = vfs.list_dir("/file.ts");
1038 assert!(result.is_err());
1039 assert!(result.unwrap_err().is_not_directory());
1040 }
1041
1042 #[test]
1043 fn test_list_dir_subdirectories() {
1044 let mut vfs = FileSystem::new();
1045 vfs.add_file("/mcp-tools/servers/test/file1.ts", "")
1046 .unwrap();
1047 vfs.add_file("/mcp-tools/servers/test/file2.ts", "")
1048 .unwrap();
1049 vfs.add_file("/mcp-tools/servers/other.ts", "").unwrap();
1050
1051 let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
1052 // Should include 'test' directory and 'other.ts' file
1053 assert_eq!(entries.len(), 2);
1054 }
1055
1056 #[test]
1057 fn test_file_count() {
1058 let mut vfs = FileSystem::new();
1059 assert_eq!(vfs.file_count(), 0);
1060
1061 vfs.add_file("/test1.ts", "").unwrap();
1062 assert_eq!(vfs.file_count(), 1);
1063
1064 vfs.add_file("/test2.ts", "").unwrap();
1065 assert_eq!(vfs.file_count(), 2);
1066 }
1067
1068 #[test]
1069 fn test_all_paths() {
1070 let mut vfs = FileSystem::new();
1071 vfs.add_file("/b.ts", "").unwrap();
1072 vfs.add_file("/a.ts", "").unwrap();
1073
1074 let paths = vfs.all_paths();
1075 assert_eq!(paths.len(), 2);
1076 // Should be sorted
1077 assert_eq!(paths[0].as_str(), "/a.ts");
1078 assert_eq!(paths[1].as_str(), "/b.ts");
1079 }
1080
1081 #[test]
1082 fn test_clear() {
1083 let mut vfs = FileSystem::new();
1084 vfs.add_file("/test1.ts", "").unwrap();
1085 vfs.add_file("/test2.ts", "").unwrap();
1086 assert_eq!(vfs.file_count(), 2);
1087
1088 vfs.clear();
1089 assert_eq!(vfs.file_count(), 0);
1090 }
1091
1092 #[test]
1093 fn test_replace_file() {
1094 let mut vfs = FileSystem::new();
1095 vfs.add_file("/test.ts", "original").unwrap();
1096 assert_eq!(vfs.read_file("/test.ts").unwrap(), "original");
1097
1098 vfs.add_file("/test.ts", "updated").unwrap();
1099 assert_eq!(vfs.read_file("/test.ts").unwrap(), "updated");
1100 assert_eq!(vfs.file_count(), 1);
1101 }
1102
1103 #[test]
1104 fn test_vfs_is_send_sync() {
1105 fn assert_send<T: Send>() {}
1106 fn assert_sync<T: Sync>() {}
1107
1108 assert_send::<FileSystem>();
1109 assert_sync::<FileSystem>();
1110 }
1111
1112 // Export tests
1113 #[test]
1114 fn test_export_single_file() {
1115 let temp = TempDir::new().unwrap();
1116 let vfs = FilesBuilder::new()
1117 .add_file("/test.ts", "export const VERSION = '1.0';")
1118 .build()
1119 .unwrap();
1120
1121 vfs.export_to_filesystem(temp.path()).unwrap();
1122
1123 let exported = temp.path().join("test.ts");
1124 assert!(exported.exists());
1125 assert_eq!(
1126 fs::read_to_string(exported).unwrap(),
1127 "export const VERSION = '1.0';"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_export_nested_files() {
1133 let temp = TempDir::new().unwrap();
1134 let vfs = FilesBuilder::new()
1135 .add_file("/tools/create.ts", "export function create() {}")
1136 .add_file("/tools/update.ts", "export function update() {}")
1137 .add_file("/manifest.json", "{}")
1138 .build()
1139 .unwrap();
1140
1141 vfs.export_to_filesystem(temp.path()).unwrap();
1142
1143 assert!(temp.path().join("tools/create.ts").exists());
1144 assert!(temp.path().join("tools/update.ts").exists());
1145 assert!(temp.path().join("manifest.json").exists());
1146 }
1147
1148 #[test]
1149 fn test_export_overwrite() {
1150 let temp = TempDir::new().unwrap();
1151 let path = temp.path().join("test.ts");
1152
1153 // Write initial file
1154 fs::write(&path, "old content").unwrap();
1155
1156 let vfs = FilesBuilder::new()
1157 .add_file("/test.ts", "new content")
1158 .build()
1159 .unwrap();
1160
1161 vfs.export_to_filesystem(temp.path()).unwrap();
1162
1163 assert_eq!(fs::read_to_string(path).unwrap(), "new content");
1164 }
1165
1166 #[test]
1167 fn test_export_atomic_writes() {
1168 let temp = TempDir::new().unwrap();
1169 let vfs = FilesBuilder::new()
1170 .add_file("/test.ts", "atomic content")
1171 .build()
1172 .unwrap();
1173
1174 let options = ExportOptions::default().with_atomic_writes(true);
1175 vfs.export_to_filesystem_with_options(temp.path(), &options)
1176 .unwrap();
1177
1178 let path = temp.path().join("test.ts");
1179 assert!(path.exists());
1180 assert_eq!(fs::read_to_string(path).unwrap(), "atomic content");
1181
1182 // Temp file should be cleaned up
1183 let temp_path = temp.path().join("test.tmp");
1184 assert!(!temp_path.exists());
1185 }
1186
1187 #[test]
1188 fn test_export_non_atomic_writes() {
1189 let temp = TempDir::new().unwrap();
1190 let vfs = FilesBuilder::new()
1191 .add_file("/test.ts", "direct content")
1192 .build()
1193 .unwrap();
1194
1195 let options = ExportOptions::default().with_atomic_writes(false);
1196 vfs.export_to_filesystem_with_options(temp.path(), &options)
1197 .unwrap();
1198
1199 let path = temp.path().join("test.ts");
1200 assert_eq!(fs::read_to_string(path).unwrap(), "direct content");
1201 }
1202
1203 #[test]
1204 fn test_export_invalid_base_path() {
1205 let vfs = FilesBuilder::new()
1206 .add_file("/test.ts", "")
1207 .build()
1208 .unwrap();
1209
1210 let result = vfs.export_to_filesystem("/nonexistent/path/that/does/not/exist");
1211 assert!(result.is_err());
1212 }
1213
1214 #[test]
1215 fn test_export_failure_leaves_existing_target_untouched() {
1216 let temp = TempDir::new().unwrap();
1217 let target = temp.path().join("out");
1218 fs::create_dir_all(&target).unwrap();
1219 fs::write(target.join("keep.ts"), "keep").unwrap();
1220
1221 // "/conflict/child.ts" forces "/conflict" to be created as a directory
1222 // in the staging tree, while "/conflict" is also added as a file —
1223 // writing it always fails (renaming a file onto an existing
1224 // directory), regardless of `HashMap` iteration order.
1225 let vfs = FilesBuilder::new()
1226 .add_file("/conflict", "file content")
1227 .add_file("/conflict/child.ts", "child content")
1228 .build()
1229 .unwrap();
1230
1231 let result = vfs.export_to_filesystem(&target);
1232 assert!(result.is_err());
1233
1234 // The previous export must be completely untouched by the failure.
1235 assert!(target.join("keep.ts").exists());
1236 assert_eq!(fs::read_to_string(target.join("keep.ts")).unwrap(), "keep");
1237 assert!(!target.join("conflict").exists());
1238
1239 // Nothing but `target` itself should remain next to it — no orphaned
1240 // staging directory left behind by the failed export.
1241 let siblings: Vec<_> = fs::read_dir(temp.path())
1242 .unwrap()
1243 .filter_map(std::result::Result::ok)
1244 .filter(|entry| entry.path() != target)
1245 .collect();
1246 assert!(siblings.is_empty(), "unexpected siblings: {siblings:?}");
1247 }
1248
1249 #[test]
1250 fn test_swap_into_place_replaces_non_empty_target() {
1251 let temp = TempDir::new().unwrap();
1252 let target = temp.path().join("out");
1253 fs::create_dir_all(&target).unwrap();
1254 fs::write(target.join("old.ts"), "old").unwrap();
1255
1256 let staging = temp.path().join("staging");
1257 fs::create_dir_all(&staging).unwrap();
1258 fs::write(staging.join("new.ts"), "new").unwrap();
1259
1260 FileSystem::swap_into_place(&staging, &target).unwrap();
1261
1262 assert!(target.join("new.ts").exists());
1263 assert!(!target.join("old.ts").exists());
1264 assert_eq!(fs::read_to_string(target.join("new.ts")).unwrap(), "new");
1265 }
1266
1267 #[test]
1268 fn test_swap_into_place_rolls_back_on_publish_failure() {
1269 let temp = TempDir::new().unwrap();
1270 let target = temp.path().join("out");
1271 fs::create_dir_all(&target).unwrap();
1272 fs::write(target.join("keep.ts"), "keep").unwrap();
1273
1274 // A staging path that does not exist forces the publish rename to fail.
1275 let missing_staging = temp.path().join("does-not-exist-staging");
1276
1277 let result = FileSystem::swap_into_place(&missing_staging, &target);
1278 assert!(result.is_err());
1279
1280 // `target` must be restored to its exact prior state, not left missing.
1281 assert!(target.join("keep.ts").exists());
1282 assert_eq!(fs::read_to_string(target.join("keep.ts")).unwrap(), "keep");
1283 }
1284
1285 #[test]
1286 fn test_sweep_stale_artifacts_removes_orphans_only() {
1287 let temp = TempDir::new().unwrap();
1288 let target = temp.path().join("out");
1289
1290 let orphan_staging = temp.path().join(".out.staging-abc123");
1291 let orphan_stale = temp.path().join(".out.stale-999-1-1");
1292 let unrelated = temp.path().join("unrelated-dir");
1293 // A staging leftover belonging to a *different* target must survive:
1294 // sweeping for `out` must never touch a concurrent export of `other`.
1295 let other_target_staging = temp.path().join(".other.staging-abc123");
1296 fs::create_dir_all(&orphan_staging).unwrap();
1297 fs::create_dir_all(&orphan_stale).unwrap();
1298 fs::create_dir_all(&unrelated).unwrap();
1299 fs::create_dir_all(&other_target_staging).unwrap();
1300
1301 // Bypass the production age gate (`Duration::ZERO`) to exercise the
1302 // name-matching logic in isolation, without backdating real mtimes.
1303 FileSystem::sweep_stale_artifacts_older_than(temp.path(), &target, Duration::ZERO);
1304
1305 assert!(!orphan_staging.exists());
1306 assert!(!orphan_stale.exists());
1307 assert!(unrelated.exists());
1308 assert!(other_target_staging.exists());
1309 }
1310
1311 #[test]
1312 fn test_export_does_not_sweep_recent_sibling_artifacts() {
1313 let temp = TempDir::new().unwrap();
1314 let target = temp.path().join("out");
1315 fs::create_dir_all(&target).unwrap();
1316
1317 // Simulate a concurrent sibling export's in-flight staging
1318 // directory, built the same way `export_to_filesystem_with_options`
1319 // builds it (fresh via `tempfile`).
1320 let sibling_staging = tempfile::Builder::new()
1321 .prefix(&FileSystem::staging_prefix(&target))
1322 .tempdir_in(temp.path())
1323 .unwrap();
1324 let sibling_staging_path = sibling_staging.path().to_path_buf();
1325
1326 // Simulate a concurrent sibling export's displaced backup, built the
1327 // same way `swap_into_place` builds it: an existing (decoy) target
1328 // renamed aside via `displace_existing_target`. Regression test for
1329 // issue #169: previously `sweep_stale_artifacts` matched purely by
1330 // name, so a real export through this same public entry point would
1331 // delete this sibling's staging and displaced directories out from
1332 // under it. `decoy_target` is fresh at test time, so this does not
1333 // itself exercise the S1 mtime-inheritance defect (a fresh target's
1334 // displaced copy would read young even without `touch_dir`) — that
1335 // is covered by `displace_existing_target_refreshes_displaced_mtime`
1336 // below. This test instead proves the sweep spares young siblings
1337 // through the full public `export_to_filesystem` path, not just the
1338 // private sweep helper.
1339 let decoy_target = temp.path().join("decoy-out");
1340 fs::create_dir_all(&decoy_target).unwrap();
1341 let sibling_displaced =
1342 FileSystem::displace_existing_target(&decoy_target, temp.path()).unwrap();
1343
1344 let vfs = FilesBuilder::new()
1345 .add_file("/tool.ts", "export {}")
1346 .build()
1347 .unwrap();
1348 vfs.export_to_filesystem(&target).unwrap();
1349
1350 assert!(
1351 sibling_staging_path.exists(),
1352 "too young to be a crash orphan"
1353 );
1354 assert!(
1355 sibling_displaced.exists(),
1356 "too young to be a crash orphan (mtime just refreshed)"
1357 );
1358 assert!(target.join("tool.ts").exists());
1359 }
1360
1361 #[test]
1362 fn displace_existing_target_refreshes_displaced_mtime() {
1363 let tmp = TempDir::new().unwrap();
1364 let target = tmp.path().join("out");
1365 fs::create_dir_all(&target).unwrap();
1366 let target_created_at = fs::metadata(&target).unwrap().modified().unwrap();
1367
1368 // Give the filesystem's mtime clock room to move past `target`'s
1369 // creation time before it gets renamed aside, so the comparison
1370 // below can distinguish "displaced kept target's original mtime"
1371 // (issue #169's S1 gap: a rename alone does not reset mtime) from
1372 // "displaced's mtime was refreshed by `touch_dir`" (the fix).
1373 std::thread::sleep(Duration::from_millis(1100));
1374
1375 let displaced = FileSystem::displace_existing_target(&target, tmp.path()).unwrap();
1376 let displaced_modified = fs::metadata(&displaced).unwrap().modified().unwrap();
1377
1378 assert!(
1379 displaced_modified > target_created_at,
1380 "displaced's mtime must be refreshed around the rename, not inherited from target"
1381 );
1382 }
1383
1384 #[test]
1385 fn sweep_stale_artifacts_skips_recent_orphans() {
1386 let tmp = TempDir::new().unwrap();
1387 let target = tmp.path().join("out");
1388
1389 let staging = tempfile::Builder::new()
1390 .prefix(&FileSystem::staging_prefix(&target))
1391 .tempdir_in(tmp.path())
1392 .unwrap();
1393 let staging_path = staging.path().to_path_buf();
1394
1395 // Real production threshold: a just-created dir must never be swept.
1396 FileSystem::sweep_stale_artifacts(tmp.path(), &target);
1397
1398 assert!(staging_path.exists(), "a fresh sibling must not be swept");
1399 }
1400
1401 #[test]
1402 fn sweep_stale_artifacts_older_than_removes_aged_orphans() {
1403 let tmp = TempDir::new().unwrap();
1404 let target = tmp.path().join("out");
1405
1406 let staging = tempfile::Builder::new()
1407 .prefix(&FileSystem::staging_prefix(&target))
1408 .tempdir_in(tmp.path())
1409 .unwrap();
1410 let staging_path = staging.keep(); // disown so the assert below is meaningful
1411
1412 FileSystem::sweep_stale_artifacts_older_than(tmp.path(), &target, Duration::ZERO);
1413
1414 assert!(
1415 !staging_path.exists(),
1416 "an orphan past the age threshold must be swept"
1417 );
1418 }
1419
1420 #[test]
1421 fn test_export_to_nonexistent_target_directory() {
1422 let temp = TempDir::new().unwrap();
1423 let target = temp.path().join("fresh-output");
1424
1425 let vfs = FilesBuilder::new()
1426 .add_file("/tools/create.ts", "export function create() {}")
1427 .build()
1428 .unwrap();
1429
1430 assert!(!target.exists());
1431 vfs.export_to_filesystem(&target).unwrap();
1432
1433 assert!(target.join("tools/create.ts").exists());
1434
1435 // Nothing but `target` itself should remain next to it — no orphaned
1436 // staging directory left behind.
1437 let siblings: Vec<_> = fs::read_dir(temp.path())
1438 .unwrap()
1439 .filter_map(std::result::Result::ok)
1440 .filter(|entry| entry.path() != target)
1441 .collect();
1442 assert!(siblings.is_empty(), "unexpected siblings: {siblings:?}");
1443 }
1444
1445 #[test]
1446 fn test_publish_staged_export_cleans_up_staging_on_failure() {
1447 let temp = TempDir::new().unwrap();
1448 let staging = TempDir::new_in(temp.path()).unwrap();
1449 fs::write(staging.path().join("file.ts"), "content").unwrap();
1450 let staging_path = staging.path().to_path_buf();
1451
1452 // Target's parent does not exist, so the publish rename fails.
1453 let target = temp.path().join("missing-parent").join("out");
1454
1455 let result = FileSystem::publish_staged_export(staging, &target);
1456 assert!(result.is_err());
1457
1458 // The now-disowned staging directory must be cleaned up, not leaked.
1459 assert!(!staging_path.exists());
1460 }
1461
1462 #[test]
1463 fn test_export_many_files() {
1464 let temp = TempDir::new().unwrap();
1465 let mut builder = FilesBuilder::new();
1466
1467 // Add 30 files (GitHub server typical case)
1468 for i in 0..30 {
1469 builder = builder.add_file(
1470 format!("/tools/tool{i}.ts"),
1471 format!("export function tool{i}() {{}}"),
1472 );
1473 }
1474
1475 let vfs = builder.build().unwrap();
1476 vfs.export_to_filesystem(temp.path()).unwrap();
1477
1478 // Verify all files exist
1479 for i in 0..30 {
1480 assert!(temp.path().join(format!("tools/tool{i}.ts")).exists());
1481 }
1482 }
1483
1484 #[test]
1485 fn test_export_deep_nesting() {
1486 let temp = TempDir::new().unwrap();
1487 let vfs = FilesBuilder::new()
1488 .add_file("/a/b/c/d/e/deep.ts", "export {}")
1489 .build()
1490 .unwrap();
1491
1492 vfs.export_to_filesystem(temp.path()).unwrap();
1493
1494 assert!(temp.path().join("a/b/c/d/e/deep.ts").exists());
1495 }
1496
1497 #[test]
1498 #[cfg(feature = "parallel")]
1499 fn test_export_parallel() {
1500 let temp = TempDir::new().unwrap();
1501 let mut builder = FilesBuilder::new();
1502
1503 for i in 0..100 {
1504 builder = builder.add_file(format!("/file{i}.ts"), format!("export const N = {i};"));
1505 }
1506
1507 let vfs = builder.build().unwrap();
1508 vfs.export_to_filesystem_parallel(temp.path()).unwrap();
1509
1510 // Verify all files exist
1511 for i in 0..100 {
1512 let path = temp.path().join(format!("file{i}.ts"));
1513 assert!(path.exists());
1514 }
1515 }
1516
1517 #[test]
1518 fn test_export_options_default() {
1519 let options = ExportOptions::default();
1520 assert!(options.atomic);
1521 }
1522
1523 #[test]
1524 fn test_export_options_builder() {
1525 let options = ExportOptions::new().with_atomic_writes(false);
1526
1527 assert!(!options.atomic);
1528 }
1529}