Skip to main content

bashkit/fs/
overlay.rs

1//! Overlay filesystem implementation.
2//!
3//! [`OverlayFs`] provides copy-on-write semantics by layering a writable upper
4//! filesystem on top of a read-only lower (base) filesystem.
5//!
6//! # Resource Limits
7//!
8//! Limits apply to the combined filesystem view (upper + lower).
9//! See [`FsLimits`](crate::FsLimits) for configuration.
10
11// RwLock.read()/write().unwrap() only panics on lock poisoning (prior panic
12// while holding lock). This is intentional - corrupted state should not propagate.
13#![allow(clippy::unwrap_used)]
14
15use async_trait::async_trait;
16use std::collections::HashSet;
17use std::io::{Error as IoError, ErrorKind};
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, RwLock};
20
21use super::limits::{FsLimits, FsUsage};
22use super::memory::InMemoryFs;
23use super::traits::{DirEntry, FileSystem, FileType, Metadata};
24use crate::error::Result;
25
26/// Copy-on-write overlay filesystem.
27///
28/// `OverlayFs` layers a writable upper filesystem on top of a read-only base
29/// (lower) filesystem, similar to Docker's overlay storage driver or Linux
30/// overlayfs.
31///
32/// # Behavior
33///
34/// - **Reads**: Check upper layer first, fall back to lower layer
35/// - **Writes**: Always go to the upper layer (copy-on-write)
36/// - **Deletes**: Tracked via whiteouts - deleted files are hidden but the lower layer is unchanged
37///
38/// # Use Cases
39///
40/// - **Template systems**: Start from a read-only template, allow modifications
41/// - **Immutable infrastructure**: Keep base images unchanged while allowing runtime modifications
42/// - **Testing**: Run tests against a base state without modifying it
43/// - **Undo support**: Discard the upper layer to "reset" to the base state
44///
45/// # Example
46///
47/// ```rust
48/// use bashkit::{Bash, FileSystem, InMemoryFs, OverlayFs};
49/// use std::path::Path;
50/// use std::sync::Arc;
51///
52/// # #[tokio::main]
53/// # async fn main() -> bashkit::Result<()> {
54/// // Create a base filesystem with template files
55/// let base = Arc::new(InMemoryFs::new());
56/// base.mkdir(Path::new("/config"), false).await?;
57/// base.write_file(Path::new("/config/app.conf"), b"debug=false").await?;
58///
59/// // Create overlay - base is read-only, changes go to overlay
60/// let overlay = Arc::new(OverlayFs::new(base.clone()));
61///
62/// // Use with Bash
63/// let mut bash = Bash::builder().fs(overlay.clone()).build();
64///
65/// // Read from base layer
66/// let result = bash.exec("cat /config/app.conf").await?;
67/// assert_eq!(result.stdout, "debug=false");
68///
69/// // Modify - changes go to overlay only
70/// bash.exec("echo 'debug=true' > /config/app.conf").await?;
71///
72/// // Overlay shows modified content
73/// let result = bash.exec("cat /config/app.conf").await?;
74/// assert_eq!(result.stdout, "debug=true\n");
75///
76/// // Base is unchanged!
77/// let original = base.read_file(Path::new("/config/app.conf")).await?;
78/// assert_eq!(original, b"debug=false");
79/// # Ok(())
80/// # }
81/// ```
82///
83/// # Whiteouts (Deletion Handling)
84///
85/// When you delete a file that exists in the base layer, `OverlayFs` creates
86/// a "whiteout" marker that hides the file without modifying the base:
87///
88/// ```rust
89/// use bashkit::{Bash, FileSystem, InMemoryFs, OverlayFs};
90/// use std::path::Path;
91/// use std::sync::Arc;
92///
93/// # #[tokio::main]
94/// # async fn main() -> bashkit::Result<()> {
95/// let base = Arc::new(InMemoryFs::new());
96/// base.write_file(Path::new("/tmp/secret.txt"), b"sensitive").await?;
97///
98/// let overlay = Arc::new(OverlayFs::new(base.clone()));
99/// let mut bash = Bash::builder().fs(overlay.clone()).build();
100///
101/// // File exists initially
102/// assert!(overlay.exists(Path::new("/tmp/secret.txt")).await?);
103///
104/// // Delete it
105/// bash.exec("rm /tmp/secret.txt").await?;
106///
107/// // Gone from overlay's view
108/// assert!(!overlay.exists(Path::new("/tmp/secret.txt")).await?);
109///
110/// // But base is unchanged
111/// assert!(base.exists(Path::new("/tmp/secret.txt")).await?);
112/// # Ok(())
113/// # }
114/// ```
115///
116/// # Directory Listing
117///
118/// When listing directories, entries from both layers are merged, with the
119/// upper layer taking precedence for files that exist in both:
120///
121/// ```rust
122/// use bashkit::{FileSystem, InMemoryFs, OverlayFs};
123/// use std::path::Path;
124/// use std::sync::Arc;
125///
126/// # #[tokio::main]
127/// # async fn main() -> bashkit::Result<()> {
128/// let base = Arc::new(InMemoryFs::new());
129/// base.write_file(Path::new("/tmp/base.txt"), b"from base").await?;
130///
131/// let overlay = OverlayFs::new(base);
132/// overlay.write_file(Path::new("/tmp/upper.txt"), b"from upper").await?;
133///
134/// // Both files visible
135/// let entries = overlay.read_dir(Path::new("/tmp")).await?;
136/// let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
137/// assert!(names.contains(&"base.txt"));
138/// assert!(names.contains(&"upper.txt"));
139/// # Ok(())
140/// # }
141/// ```
142pub struct OverlayFs {
143    /// Lower (read-only base) filesystem
144    lower: Arc<dyn FileSystem>,
145    /// Upper (writable) filesystem - always InMemoryFs
146    upper: InMemoryFs,
147    /// Paths that have been deleted (whiteouts)
148    whiteouts: RwLock<HashSet<PathBuf>>,
149    /// Combined limits for the overlay view
150    limits: FsLimits,
151}
152
153impl OverlayFs {
154    /// Create a new overlay filesystem with the given base layer and default limits.
155    ///
156    /// The `lower` filesystem is treated as read-only - all reads will first
157    /// check the upper layer, then fall back to the lower layer. All writes
158    /// go to a new [`InMemoryFs`] upper layer.
159    ///
160    /// # Arguments
161    ///
162    /// * `lower` - The base (read-only) filesystem
163    ///
164    /// # Example
165    ///
166    /// ```rust
167    /// use bashkit::{FileSystem, InMemoryFs, OverlayFs};
168    /// use std::path::Path;
169    /// use std::sync::Arc;
170    ///
171    /// # #[tokio::main]
172    /// # async fn main() -> bashkit::Result<()> {
173    /// // Create base with some files
174    /// let base = Arc::new(InMemoryFs::new());
175    /// base.mkdir(Path::new("/data"), false).await?;
176    /// base.write_file(Path::new("/data/readme.txt"), b"Read me!").await?;
177    ///
178    /// // Create overlay
179    /// let overlay = OverlayFs::new(base);
180    ///
181    /// // Can read from base
182    /// let content = overlay.read_file(Path::new("/data/readme.txt")).await?;
183    /// assert_eq!(content, b"Read me!");
184    ///
185    /// // Writes go to upper layer
186    /// overlay.write_file(Path::new("/data/new.txt"), b"New file").await?;
187    /// # Ok(())
188    /// # }
189    /// ```
190    pub fn new(lower: Arc<dyn FileSystem>) -> Self {
191        Self::with_limits(lower, FsLimits::default())
192    }
193
194    /// Create a new overlay filesystem with custom limits.
195    ///
196    /// Limits apply to the combined view (upper layer writes + lower layer content).
197    ///
198    /// # Example
199    ///
200    /// ```rust
201    /// use bashkit::{FileSystem, InMemoryFs, OverlayFs, FsLimits};
202    /// use std::path::Path;
203    /// use std::sync::Arc;
204    ///
205    /// # #[tokio::main]
206    /// # async fn main() -> bashkit::Result<()> {
207    /// let base = Arc::new(InMemoryFs::new());
208    /// let limits = FsLimits::new().max_total_bytes(10_000_000); // 10MB
209    ///
210    /// let overlay = OverlayFs::with_limits(base, limits);
211    /// # Ok(())
212    /// # }
213    /// ```
214    pub fn with_limits(lower: Arc<dyn FileSystem>, limits: FsLimits) -> Self {
215        // Upper layer uses unlimited limits - we enforce limits at the OverlayFs level
216        Self {
217            lower,
218            upper: InMemoryFs::with_limits(FsLimits::unlimited()),
219            whiteouts: RwLock::new(HashSet::new()),
220            limits,
221        }
222    }
223
224    /// Access the upper (writable) filesystem layer.
225    ///
226    /// This provides direct access to the [`InMemoryFs`] that stores all writes.
227    /// Useful for pre-populating files during construction.
228    ///
229    /// # Example
230    ///
231    /// ```rust
232    /// use bashkit::{InMemoryFs, OverlayFs};
233    /// use std::sync::Arc;
234    ///
235    /// let base = Arc::new(InMemoryFs::new());
236    /// let overlay = OverlayFs::new(base);
237    ///
238    /// // Add files directly to upper layer
239    /// overlay.upper().add_file("/config/app.conf", "debug=true\n", 0o644);
240    /// ```
241    pub fn upper(&self) -> &InMemoryFs {
242        &self.upper
243    }
244
245    /// Compute combined usage (upper + visible lower).
246    fn compute_usage(&self) -> FsUsage {
247        // Get upper layer usage
248        let upper_usage = self.upper.usage();
249
250        // Lower layer usage is counted but we don't double-count
251        // files that are overwritten in upper or whited out
252        let lower_usage = self.lower.usage();
253
254        // Combine both layers
255        let total_bytes = upper_usage.total_bytes + lower_usage.total_bytes;
256        let file_count = upper_usage.file_count + lower_usage.file_count;
257        let dir_count = upper_usage.dir_count + lower_usage.dir_count;
258
259        FsUsage::new(total_bytes, file_count, dir_count)
260    }
261
262    /// Check limits before writing.
263    fn check_write_limits(&self, content_size: usize) -> Result<()> {
264        // Check file size limit
265        if content_size as u64 > self.limits.max_file_size {
266            return Err(IoError::other(format!(
267                "file too large: {} bytes exceeds {} byte limit",
268                content_size, self.limits.max_file_size
269            ))
270            .into());
271        }
272
273        // Check total size limit (upper layer only, since lower is read-only)
274        let usage = self.upper.usage();
275        let new_total = usage.total_bytes + content_size as u64;
276        if new_total > self.limits.max_total_bytes {
277            return Err(IoError::other(format!(
278                "filesystem full: {} bytes would exceed {} byte limit",
279                new_total, self.limits.max_total_bytes
280            ))
281            .into());
282        }
283
284        // Check file count limit
285        if usage.file_count >= self.limits.max_file_count {
286            return Err(IoError::other(format!(
287                "too many files: {} files at {} file limit",
288                usage.file_count, self.limits.max_file_count
289            ))
290            .into());
291        }
292
293        Ok(())
294    }
295
296    /// Normalize a path for consistent lookups
297    fn normalize_path(path: &Path) -> PathBuf {
298        let mut result = PathBuf::new();
299
300        for component in path.components() {
301            match component {
302                std::path::Component::RootDir => {
303                    result.push("/");
304                }
305                std::path::Component::Normal(name) => {
306                    result.push(name);
307                }
308                std::path::Component::ParentDir => {
309                    result.pop();
310                }
311                std::path::Component::CurDir => {}
312                std::path::Component::Prefix(_) => {}
313            }
314        }
315
316        if result.as_os_str().is_empty() {
317            result.push("/");
318        }
319
320        result
321    }
322
323    /// Check if a path has been deleted (whiteout)
324    fn is_whiteout(&self, path: &Path) -> bool {
325        let path = Self::normalize_path(path);
326        let whiteouts = self.whiteouts.read().unwrap();
327        whiteouts.contains(&path)
328    }
329
330    /// Mark a path as deleted (add whiteout)
331    fn add_whiteout(&self, path: &Path) {
332        let path = Self::normalize_path(path);
333        let mut whiteouts = self.whiteouts.write().unwrap();
334        whiteouts.insert(path);
335    }
336
337    /// Remove a whiteout (for when re-creating a deleted file)
338    fn remove_whiteout(&self, path: &Path) {
339        let path = Self::normalize_path(path);
340        let mut whiteouts = self.whiteouts.write().unwrap();
341        whiteouts.remove(&path);
342    }
343}
344
345#[async_trait]
346impl FileSystem for OverlayFs {
347    async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
348        let path = Self::normalize_path(path);
349
350        // Check for whiteout (deleted file)
351        if self.is_whiteout(&path) {
352            return Err(IoError::new(ErrorKind::NotFound, "file not found").into());
353        }
354
355        // Try upper first
356        if self.upper.exists(&path).await.unwrap_or(false) {
357            return self.upper.read_file(&path).await;
358        }
359
360        // Fall back to lower
361        self.lower.read_file(&path).await
362    }
363
364    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
365        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
366        self.limits
367            .validate_path(path)
368            .map_err(|e| IoError::other(e.to_string()))?;
369
370        let path = Self::normalize_path(path);
371
372        // Check limits before writing
373        self.check_write_limits(content.len())?;
374
375        // Remove any whiteout for this path
376        self.remove_whiteout(&path);
377
378        // Ensure parent directory exists in upper
379        if let Some(parent) = path.parent() {
380            if !self.upper.exists(parent).await.unwrap_or(false) {
381                // Copy parent directory structure from lower if it exists
382                if self.lower.exists(parent).await.unwrap_or(false) {
383                    self.upper.mkdir(parent, true).await?;
384                } else {
385                    return Err(
386                        IoError::new(ErrorKind::NotFound, "parent directory not found").into(),
387                    );
388                }
389            }
390        }
391
392        // Write to upper
393        self.upper.write_file(&path, content).await
394    }
395
396    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
397        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
398        self.limits
399            .validate_path(path)
400            .map_err(|e| IoError::other(e.to_string()))?;
401
402        let path = Self::normalize_path(path);
403
404        // Check for whiteout
405        if self.is_whiteout(&path) {
406            return Err(IoError::new(ErrorKind::NotFound, "file not found").into());
407        }
408
409        // If file exists in upper, append there
410        if self.upper.exists(&path).await.unwrap_or(false) {
411            // Check limits for appended content
412            self.check_write_limits(content.len())?;
413            return self.upper.append_file(&path, content).await;
414        }
415
416        // If file exists in lower, copy-on-write
417        if self.lower.exists(&path).await.unwrap_or(false) {
418            let existing = self.lower.read_file(&path).await?;
419
420            // Check limits for combined content
421            self.check_write_limits(existing.len() + content.len())?;
422
423            // Ensure parent exists in upper
424            if let Some(parent) = path.parent() {
425                if !self.upper.exists(parent).await.unwrap_or(false) {
426                    self.upper.mkdir(parent, true).await?;
427                }
428            }
429
430            // Copy existing content and append new content
431            let mut combined = existing;
432            combined.extend_from_slice(content);
433            return self.upper.write_file(&path, &combined).await;
434        }
435
436        // Create new file in upper
437        self.check_write_limits(content.len())?;
438        self.upper.write_file(&path, content).await
439    }
440
441    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
442        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
443        self.limits
444            .validate_path(path)
445            .map_err(|e| IoError::other(e.to_string()))?;
446
447        let path = Self::normalize_path(path);
448
449        // Remove any whiteout for this path
450        self.remove_whiteout(&path);
451
452        // Create in upper
453        self.upper.mkdir(&path, recursive).await
454    }
455
456    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
457        let path = Self::normalize_path(path);
458
459        // Check if exists in either layer
460        let in_upper = self.upper.exists(&path).await.unwrap_or(false);
461        let in_lower = !self.is_whiteout(&path) && self.lower.exists(&path).await.unwrap_or(false);
462
463        if !in_upper && !in_lower {
464            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
465        }
466
467        // Remove from upper if present
468        if in_upper {
469            self.upper.remove(&path, recursive).await?;
470        }
471
472        // If was in lower, add whiteout
473        if in_lower {
474            if recursive {
475                // Add whiteouts for all paths under this directory
476                // This is a simplification - real overlayfs uses opaque dirs
477                self.add_whiteout(&path);
478            } else {
479                self.add_whiteout(&path);
480            }
481        }
482
483        Ok(())
484    }
485
486    async fn stat(&self, path: &Path) -> Result<Metadata> {
487        let path = Self::normalize_path(path);
488
489        // Check for whiteout
490        if self.is_whiteout(&path) {
491            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
492        }
493
494        // Try upper first
495        if self.upper.exists(&path).await.unwrap_or(false) {
496            return self.upper.stat(&path).await;
497        }
498
499        // Fall back to lower
500        self.lower.stat(&path).await
501    }
502
503    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
504        let path = Self::normalize_path(path);
505
506        // Check for whiteout
507        if self.is_whiteout(&path) {
508            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
509        }
510
511        let mut entries: std::collections::HashMap<String, DirEntry> =
512            std::collections::HashMap::new();
513
514        // Get entries from lower (if not whited out)
515        if self.lower.exists(&path).await.unwrap_or(false) {
516            if let Ok(lower_entries) = self.lower.read_dir(&path).await {
517                for entry in lower_entries {
518                    // Skip whited out entries
519                    let entry_path = path.join(&entry.name);
520                    if !self.is_whiteout(&entry_path) {
521                        entries.insert(entry.name.clone(), entry);
522                    }
523                }
524            }
525        }
526
527        // Overlay with entries from upper (overriding lower)
528        if self.upper.exists(&path).await.unwrap_or(false) {
529            if let Ok(upper_entries) = self.upper.read_dir(&path).await {
530                for entry in upper_entries {
531                    entries.insert(entry.name.clone(), entry);
532                }
533            }
534        }
535
536        Ok(entries.into_values().collect())
537    }
538
539    async fn exists(&self, path: &Path) -> Result<bool> {
540        let path = Self::normalize_path(path);
541
542        // Check for whiteout
543        if self.is_whiteout(&path) {
544            return Ok(false);
545        }
546
547        // Check upper first
548        if self.upper.exists(&path).await.unwrap_or(false) {
549            return Ok(true);
550        }
551
552        // Check lower
553        self.lower.exists(&path).await
554    }
555
556    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
557        let from = Self::normalize_path(from);
558        let to = Self::normalize_path(to);
559
560        // Read from source (checking both layers)
561        let content = self.read_file(&from).await?;
562
563        // Write to destination in upper
564        self.write_file(&to, &content).await?;
565
566        // Delete source (will add whiteout if needed)
567        self.remove(&from, false).await?;
568
569        Ok(())
570    }
571
572    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
573        let from = Self::normalize_path(from);
574        let to = Self::normalize_path(to);
575
576        // Read from source (checking both layers)
577        let content = self.read_file(&from).await?;
578
579        // Write to destination in upper
580        self.write_file(&to, &content).await
581    }
582
583    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
584        let link = Self::normalize_path(link);
585
586        // Remove any whiteout
587        self.remove_whiteout(&link);
588
589        // Create symlink in upper
590        self.upper.symlink(target, &link).await
591    }
592
593    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
594        let path = Self::normalize_path(path);
595
596        // Check for whiteout
597        if self.is_whiteout(&path) {
598            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
599        }
600
601        // Try upper first
602        if self.upper.exists(&path).await.unwrap_or(false) {
603            return self.upper.read_link(&path).await;
604        }
605
606        // Fall back to lower
607        self.lower.read_link(&path).await
608    }
609
610    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
611        let path = Self::normalize_path(path);
612
613        // Check for whiteout
614        if self.is_whiteout(&path) {
615            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
616        }
617
618        // If exists in upper, chmod there
619        if self.upper.exists(&path).await.unwrap_or(false) {
620            return self.upper.chmod(&path, mode).await;
621        }
622
623        // If exists in lower, copy-on-write metadata
624        if self.lower.exists(&path).await.unwrap_or(false) {
625            let stat = self.lower.stat(&path).await?;
626
627            // Create in upper with same content (for files)
628            if stat.file_type == FileType::File {
629                let content = self.lower.read_file(&path).await?;
630                self.upper.write_file(&path, &content).await?;
631            } else if stat.file_type == FileType::Directory {
632                self.upper.mkdir(&path, true).await?;
633            }
634
635            return self.upper.chmod(&path, mode).await;
636        }
637
638        Err(IoError::new(ErrorKind::NotFound, "not found").into())
639    }
640
641    fn usage(&self) -> FsUsage {
642        self.compute_usage()
643    }
644
645    fn limits(&self) -> FsLimits {
646        self.limits.clone()
647    }
648}
649
650#[cfg(test)]
651#[allow(clippy::unwrap_used)]
652mod tests {
653    use super::*;
654
655    #[tokio::test]
656    async fn test_read_from_lower() {
657        let lower = Arc::new(InMemoryFs::new());
658        lower
659            .write_file(Path::new("/tmp/test.txt"), b"hello")
660            .await
661            .unwrap();
662
663        let overlay = OverlayFs::new(lower);
664        let content = overlay.read_file(Path::new("/tmp/test.txt")).await.unwrap();
665        assert_eq!(content, b"hello");
666    }
667
668    #[tokio::test]
669    async fn test_write_to_upper() {
670        let lower = Arc::new(InMemoryFs::new());
671        let overlay = OverlayFs::new(lower.clone());
672
673        overlay
674            .write_file(Path::new("/tmp/new.txt"), b"new file")
675            .await
676            .unwrap();
677
678        // Should be readable from overlay
679        let content = overlay.read_file(Path::new("/tmp/new.txt")).await.unwrap();
680        assert_eq!(content, b"new file");
681
682        // Should NOT be in lower
683        assert!(!lower.exists(Path::new("/tmp/new.txt")).await.unwrap());
684    }
685
686    #[tokio::test]
687    async fn test_copy_on_write() {
688        let lower = Arc::new(InMemoryFs::new());
689        lower
690            .write_file(Path::new("/tmp/test.txt"), b"original")
691            .await
692            .unwrap();
693
694        let overlay = OverlayFs::new(lower.clone());
695
696        // Modify through overlay
697        overlay
698            .write_file(Path::new("/tmp/test.txt"), b"modified")
699            .await
700            .unwrap();
701
702        // Overlay should show modified
703        let content = overlay.read_file(Path::new("/tmp/test.txt")).await.unwrap();
704        assert_eq!(content, b"modified");
705
706        // Lower should still have original
707        let lower_content = lower.read_file(Path::new("/tmp/test.txt")).await.unwrap();
708        assert_eq!(lower_content, b"original");
709    }
710
711    #[tokio::test]
712    async fn test_delete_with_whiteout() {
713        let lower = Arc::new(InMemoryFs::new());
714        lower
715            .write_file(Path::new("/tmp/test.txt"), b"hello")
716            .await
717            .unwrap();
718
719        let overlay = OverlayFs::new(lower.clone());
720
721        // Delete through overlay
722        overlay
723            .remove(Path::new("/tmp/test.txt"), false)
724            .await
725            .unwrap();
726
727        // Should not be visible through overlay
728        assert!(!overlay.exists(Path::new("/tmp/test.txt")).await.unwrap());
729
730        // But should still exist in lower
731        assert!(lower.exists(Path::new("/tmp/test.txt")).await.unwrap());
732    }
733
734    #[tokio::test]
735    async fn test_recreate_after_delete() {
736        let lower = Arc::new(InMemoryFs::new());
737        lower
738            .write_file(Path::new("/tmp/test.txt"), b"original")
739            .await
740            .unwrap();
741
742        let overlay = OverlayFs::new(lower);
743
744        // Delete
745        overlay
746            .remove(Path::new("/tmp/test.txt"), false)
747            .await
748            .unwrap();
749        assert!(!overlay.exists(Path::new("/tmp/test.txt")).await.unwrap());
750
751        // Recreate
752        overlay
753            .write_file(Path::new("/tmp/test.txt"), b"new content")
754            .await
755            .unwrap();
756
757        // Should now exist with new content
758        assert!(overlay.exists(Path::new("/tmp/test.txt")).await.unwrap());
759        let content = overlay.read_file(Path::new("/tmp/test.txt")).await.unwrap();
760        assert_eq!(content, b"new content");
761    }
762
763    #[tokio::test]
764    async fn test_read_dir_merged() {
765        let lower = Arc::new(InMemoryFs::new());
766        lower
767            .write_file(Path::new("/tmp/lower.txt"), b"lower")
768            .await
769            .unwrap();
770
771        let overlay = OverlayFs::new(lower);
772        overlay
773            .write_file(Path::new("/tmp/upper.txt"), b"upper")
774            .await
775            .unwrap();
776
777        let entries = overlay.read_dir(Path::new("/tmp")).await.unwrap();
778        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
779
780        assert!(names.contains(&&"lower.txt".to_string()));
781        assert!(names.contains(&&"upper.txt".to_string()));
782    }
783}