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}