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    // Tracks lower-layer usage that is hidden by upper overrides or whiteouts.
152    // Updated incrementally in async methods so compute_usage (sync) stays accurate.
153    lower_hidden: RwLock<FsUsage>,
154}
155
156impl OverlayFs {
157    /// Create a new overlay filesystem with the given base layer and default limits.
158    ///
159    /// The `lower` filesystem is treated as read-only - all reads will first
160    /// check the upper layer, then fall back to the lower layer. All writes
161    /// go to a new [`InMemoryFs`] upper layer.
162    ///
163    /// # Arguments
164    ///
165    /// * `lower` - The base (read-only) filesystem
166    ///
167    /// # Example
168    ///
169    /// ```rust
170    /// use bashkit::{FileSystem, InMemoryFs, OverlayFs};
171    /// use std::path::Path;
172    /// use std::sync::Arc;
173    ///
174    /// # #[tokio::main]
175    /// # async fn main() -> bashkit::Result<()> {
176    /// // Create base with some files
177    /// let base = Arc::new(InMemoryFs::new());
178    /// base.mkdir(Path::new("/data"), false).await?;
179    /// base.write_file(Path::new("/data/readme.txt"), b"Read me!").await?;
180    ///
181    /// // Create overlay
182    /// let overlay = OverlayFs::new(base);
183    ///
184    /// // Can read from base
185    /// let content = overlay.read_file(Path::new("/data/readme.txt")).await?;
186    /// assert_eq!(content, b"Read me!");
187    ///
188    /// // Writes go to upper layer
189    /// overlay.write_file(Path::new("/data/new.txt"), b"New file").await?;
190    /// # Ok(())
191    /// # }
192    /// ```
193    pub fn new(lower: Arc<dyn FileSystem>) -> Self {
194        Self::with_limits(lower, FsLimits::default())
195    }
196
197    /// Create a new overlay filesystem with custom limits.
198    ///
199    /// Limits apply to the combined view (upper layer writes + lower layer content).
200    ///
201    /// # Example
202    ///
203    /// ```rust
204    /// use bashkit::{FileSystem, InMemoryFs, OverlayFs, FsLimits};
205    /// use std::path::Path;
206    /// use std::sync::Arc;
207    ///
208    /// # #[tokio::main]
209    /// # async fn main() -> bashkit::Result<()> {
210    /// let base = Arc::new(InMemoryFs::new());
211    /// let limits = FsLimits::new().max_total_bytes(10_000_000); // 10MB
212    ///
213    /// let overlay = OverlayFs::with_limits(base, limits);
214    /// # Ok(())
215    /// # }
216    /// ```
217    pub fn with_limits(lower: Arc<dyn FileSystem>, limits: FsLimits) -> Self {
218        // Upper layer uses unlimited limits - we enforce limits at the OverlayFs level
219        Self {
220            lower,
221            upper: InMemoryFs::with_limits(FsLimits::unlimited()),
222            whiteouts: RwLock::new(HashSet::new()),
223            limits,
224            lower_hidden: RwLock::new(FsUsage::default()),
225        }
226    }
227
228    /// Access the upper (writable) filesystem layer.
229    ///
230    /// This provides direct access to the [`InMemoryFs`] that stores all writes.
231    /// Useful for pre-populating files during construction.
232    ///
233    /// # Example
234    ///
235    /// ```rust
236    /// use bashkit::{InMemoryFs, OverlayFs};
237    /// use std::sync::Arc;
238    ///
239    /// let base = Arc::new(InMemoryFs::new());
240    /// let overlay = OverlayFs::new(base);
241    ///
242    /// // Add files directly to upper layer
243    /// overlay.upper().add_file("/config/app.conf", "debug=true\n", 0o644);
244    /// ```
245    pub fn upper(&self) -> &InMemoryFs {
246        &self.upper
247    }
248
249    /// Compute combined usage (upper + visible lower).
250    ///
251    /// Deducts lower-layer entries that are hidden by upper overrides or whiteouts
252    /// to avoid double-counting. The `lower_hidden` accumulator is maintained
253    /// incrementally by write_file, remove, chmod, and related async methods.
254    fn compute_usage(&self) -> FsUsage {
255        let upper_usage = self.upper.usage();
256        let lower_usage = self.lower.usage();
257        let hidden = self.lower_hidden.read().unwrap();
258
259        let total_bytes = upper_usage
260            .total_bytes
261            .saturating_add(lower_usage.total_bytes)
262            .saturating_sub(hidden.total_bytes);
263        let file_count = upper_usage
264            .file_count
265            .saturating_add(lower_usage.file_count)
266            .saturating_sub(hidden.file_count);
267        let dir_count = upper_usage
268            .dir_count
269            .saturating_add(lower_usage.dir_count)
270            .saturating_sub(hidden.dir_count);
271
272        FsUsage::new(total_bytes, file_count, dir_count)
273    }
274
275    /// Record a lower-layer file as hidden (overridden or whited out).
276    fn hide_lower_file(&self, size: u64) {
277        let mut h = self.lower_hidden.write().unwrap();
278        h.total_bytes = h.total_bytes.saturating_add(size);
279        h.file_count = h.file_count.saturating_add(1);
280    }
281
282    /// Record a lower-layer directory as hidden.
283    fn hide_lower_dir(&self) {
284        let mut h = self.lower_hidden.write().unwrap();
285        h.dir_count = h.dir_count.saturating_add(1);
286    }
287
288    /// Recursively enumerate lower-layer children and record them as hidden.
289    /// Called during recursive directory delete so usage stays accurate.
290    async fn hide_lower_children_recursive(&self, dir: &Path) {
291        if let Ok(entries) = self.lower.read_dir(dir).await {
292            for entry in entries {
293                let child = dir.join(&entry.name);
294                if let Ok(meta) = self.lower.stat(&child).await {
295                    match meta.file_type {
296                        FileType::File => self.hide_lower_file(meta.size),
297                        FileType::Directory => {
298                            self.hide_lower_dir();
299                            // Recurse into subdirectories
300                            Box::pin(self.hide_lower_children_recursive(&child)).await;
301                        }
302                        _ => {}
303                    }
304                }
305            }
306        }
307    }
308
309    /// Check limits before writing.
310    fn check_write_limits(&self, content_size: usize) -> Result<()> {
311        // Check file size limit
312        if content_size as u64 > self.limits.max_file_size {
313            return Err(IoError::other(format!(
314                "file too large: {} bytes exceeds {} byte limit",
315                content_size, self.limits.max_file_size
316            ))
317            .into());
318        }
319
320        // THREAT[TM-DOS-035]: Check total size against combined usage, not just upper.
321        // Using upper-only would allow exceeding limits when lower has existing data.
322        let usage = self.compute_usage();
323        let new_total = usage.total_bytes + content_size as u64;
324        if new_total > self.limits.max_total_bytes {
325            return Err(IoError::other(format!(
326                "filesystem full: {} bytes would exceed {} byte limit",
327                new_total, self.limits.max_total_bytes
328            ))
329            .into());
330        }
331
332        // Check file count limit
333        if usage.file_count >= self.limits.max_file_count {
334            return Err(IoError::other(format!(
335                "too many files: {} files at {} file limit",
336                usage.file_count, self.limits.max_file_count
337            ))
338            .into());
339        }
340
341        Ok(())
342    }
343
344    /// Normalize a path for consistent lookups
345    fn normalize_path(path: &Path) -> PathBuf {
346        let mut result = PathBuf::new();
347
348        for component in path.components() {
349            match component {
350                std::path::Component::RootDir => {
351                    result.push("/");
352                }
353                std::path::Component::Normal(name) => {
354                    result.push(name);
355                }
356                std::path::Component::ParentDir => {
357                    result.pop();
358                }
359                std::path::Component::CurDir => {}
360                std::path::Component::Prefix(_) => {}
361            }
362        }
363
364        if result.as_os_str().is_empty() {
365            result.push("/");
366        }
367
368        result
369    }
370
371    /// Check if a path has been deleted (whiteout)
372    fn is_whiteout(&self, path: &Path) -> bool {
373        let path = Self::normalize_path(path);
374        let whiteouts = self.whiteouts.read().unwrap();
375        // THREAT[TM-DOS-038]: Check path itself and all ancestors.
376        // Recursive delete whiteouts the directory; children inherit invisibility.
377        let mut check = path.as_path();
378        loop {
379            if whiteouts.contains(check) {
380                return true;
381            }
382            match check.parent() {
383                Some(p) if p != check => check = p,
384                _ => break,
385            }
386        }
387        false
388    }
389
390    /// Mark a path as deleted (add whiteout)
391    fn add_whiteout(&self, path: &Path) {
392        let path = Self::normalize_path(path);
393        let mut whiteouts = self.whiteouts.write().unwrap();
394        whiteouts.insert(path);
395    }
396
397    /// Remove a whiteout (for when re-creating a deleted file)
398    fn remove_whiteout(&self, path: &Path) {
399        let path = Self::normalize_path(path);
400        let mut whiteouts = self.whiteouts.write().unwrap();
401        whiteouts.remove(&path);
402    }
403}
404
405#[async_trait]
406impl FileSystem for OverlayFs {
407    async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
408        let path = Self::normalize_path(path);
409
410        // Check for whiteout (deleted file)
411        if self.is_whiteout(&path) {
412            return Err(IoError::new(ErrorKind::NotFound, "file not found").into());
413        }
414
415        // Try upper first
416        if self.upper.exists(&path).await.unwrap_or(false) {
417            return self.upper.read_file(&path).await;
418        }
419
420        // Fall back to lower
421        self.lower.read_file(&path).await
422    }
423
424    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
425        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
426        self.limits
427            .validate_path(path)
428            .map_err(|e| IoError::other(e.to_string()))?;
429
430        let path = Self::normalize_path(path);
431
432        // Check limits before writing
433        self.check_write_limits(content.len())?;
434
435        // Track whether lower file becomes newly hidden by this write.
436        // If the path is not already in upper AND not already whited out,
437        // then writing to upper will newly shadow the lower entry.
438        let already_in_upper = self.upper.exists(&path).await.unwrap_or(false);
439        let already_whited = self.is_whiteout(&path);
440        let lower_exists = self.lower.exists(&path).await.unwrap_or(false);
441
442        // Remove any whiteout for this path (upper override takes over hiding)
443        self.remove_whiteout(&path);
444
445        // Ensure parent directory exists in upper
446        if let Some(parent) = path.parent() {
447            if !self.upper.exists(parent).await.unwrap_or(false) {
448                // Copy parent directory structure from lower if it exists
449                if self.lower.exists(parent).await.unwrap_or(false) {
450                    self.upper.mkdir(parent, true).await?;
451                } else {
452                    return Err(
453                        IoError::new(ErrorKind::NotFound, "parent directory not found").into(),
454                    );
455                }
456            }
457        }
458
459        // Write to upper
460        self.upper.write_file(&path, content).await?;
461
462        // If this write newly hides a lower file (not previously hidden by
463        // upper override or whiteout), record the hidden lower contribution.
464        if lower_exists && !already_in_upper && !already_whited {
465            if let Ok(meta) = self.lower.stat(&path).await {
466                match meta.file_type {
467                    FileType::File => self.hide_lower_file(meta.size),
468                    FileType::Directory => self.hide_lower_dir(),
469                    _ => {}
470                }
471            }
472        }
473
474        Ok(())
475    }
476
477    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
478        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
479        self.limits
480            .validate_path(path)
481            .map_err(|e| IoError::other(e.to_string()))?;
482
483        let path = Self::normalize_path(path);
484
485        // Check for whiteout
486        if self.is_whiteout(&path) {
487            return Err(IoError::new(ErrorKind::NotFound, "file not found").into());
488        }
489
490        // If file exists in upper, append there
491        if self.upper.exists(&path).await.unwrap_or(false) {
492            // Check limits for appended content
493            self.check_write_limits(content.len())?;
494            return self.upper.append_file(&path, content).await;
495        }
496
497        // If file exists in lower, copy-on-write
498        if self.lower.exists(&path).await.unwrap_or(false) {
499            let lower_meta = self.lower.stat(&path).await?;
500            let existing = self.lower.read_file(&path).await?;
501
502            // Check limits for combined content
503            self.check_write_limits(existing.len() + content.len())?;
504
505            // Ensure parent exists in upper
506            if let Some(parent) = path.parent() {
507                if !self.upper.exists(parent).await.unwrap_or(false) {
508                    self.upper.mkdir(parent, true).await?;
509                }
510            }
511
512            // Copy existing content and append new content
513            let mut combined = existing;
514            combined.extend_from_slice(content);
515            self.upper.write_file(&path, &combined).await?;
516
517            // Lower file is now hidden by the upper copy
518            self.hide_lower_file(lower_meta.size);
519            return Ok(());
520        }
521
522        // Create new file in upper
523        self.check_write_limits(content.len())?;
524        self.upper.write_file(&path, content).await
525    }
526
527    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
528        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
529        self.limits
530            .validate_path(path)
531            .map_err(|e| IoError::other(e.to_string()))?;
532
533        let path = Self::normalize_path(path);
534
535        // Remove any whiteout for this path
536        self.remove_whiteout(&path);
537
538        // Create in upper
539        self.upper.mkdir(&path, recursive).await
540    }
541
542    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
543        let path = Self::normalize_path(path);
544
545        // Check if exists in either layer
546        let in_upper = self.upper.exists(&path).await.unwrap_or(false);
547        let in_lower = !self.is_whiteout(&path) && self.lower.exists(&path).await.unwrap_or(false);
548
549        if !in_upper && !in_lower {
550            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
551        }
552
553        // Remove from upper if present
554        if in_upper {
555            self.upper.remove(&path, recursive).await?;
556        }
557
558        // If was in lower, add whiteout and track hiding.
559        // If in_upper was also true, the lower was already hidden (by the upper
560        // override). The whiteout replaces the override as the hiding mechanism,
561        // so no additional deduction needed.
562        if in_lower {
563            // Newly hiding the lower entry only if there was no upper override
564            if !in_upper {
565                if let Ok(meta) = self.lower.stat(&path).await {
566                    match meta.file_type {
567                        FileType::File => self.hide_lower_file(meta.size),
568                        FileType::Directory => {
569                            self.hide_lower_dir();
570                            // THREAT[TM-DOS-038]: Recursive delete must track all
571                            // lower children for accurate usage deduction.
572                            if recursive {
573                                self.hide_lower_children_recursive(&path).await;
574                            }
575                        }
576                        _ => {}
577                    }
578                }
579            }
580            self.add_whiteout(&path);
581        }
582
583        Ok(())
584    }
585
586    async fn stat(&self, path: &Path) -> Result<Metadata> {
587        let path = Self::normalize_path(path);
588
589        // Check for whiteout
590        if self.is_whiteout(&path) {
591            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
592        }
593
594        // Try upper first
595        if self.upper.exists(&path).await.unwrap_or(false) {
596            return self.upper.stat(&path).await;
597        }
598
599        // Fall back to lower
600        self.lower.stat(&path).await
601    }
602
603    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
604        let path = Self::normalize_path(path);
605
606        // Check for whiteout
607        if self.is_whiteout(&path) {
608            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
609        }
610
611        let mut entries: std::collections::HashMap<String, DirEntry> =
612            std::collections::HashMap::new();
613
614        // Get entries from lower (if not whited out)
615        if self.lower.exists(&path).await.unwrap_or(false) {
616            if let Ok(lower_entries) = self.lower.read_dir(&path).await {
617                for entry in lower_entries {
618                    // Skip whited out entries
619                    let entry_path = path.join(&entry.name);
620                    if !self.is_whiteout(&entry_path) {
621                        entries.insert(entry.name.clone(), entry);
622                    }
623                }
624            }
625        }
626
627        // Overlay with entries from upper (overriding lower)
628        if self.upper.exists(&path).await.unwrap_or(false) {
629            if let Ok(upper_entries) = self.upper.read_dir(&path).await {
630                for entry in upper_entries {
631                    entries.insert(entry.name.clone(), entry);
632                }
633            }
634        }
635
636        Ok(entries.into_values().collect())
637    }
638
639    async fn exists(&self, path: &Path) -> Result<bool> {
640        let path = Self::normalize_path(path);
641
642        // Check for whiteout
643        if self.is_whiteout(&path) {
644            return Ok(false);
645        }
646
647        // Check upper first
648        if self.upper.exists(&path).await.unwrap_or(false) {
649            return Ok(true);
650        }
651
652        // Check lower
653        self.lower.exists(&path).await
654    }
655
656    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
657        let from = Self::normalize_path(from);
658        let to = Self::normalize_path(to);
659
660        // Read from source (checking both layers)
661        let content = self.read_file(&from).await?;
662
663        // Write to destination in upper
664        self.write_file(&to, &content).await?;
665
666        // Delete source (will add whiteout if needed)
667        self.remove(&from, false).await?;
668
669        Ok(())
670    }
671
672    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
673        let from = Self::normalize_path(from);
674        let to = Self::normalize_path(to);
675
676        // Read from source (checking both layers)
677        let content = self.read_file(&from).await?;
678
679        // Write to destination in upper
680        self.write_file(&to, &content).await
681    }
682
683    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
684        // THREAT[TM-DOS-045]: Validate path and enforce limits like other write methods
685        self.limits
686            .validate_path(link)
687            .map_err(|e| IoError::other(e.to_string()))?;
688
689        let link = Self::normalize_path(link);
690
691        // Check write limits (symlinks count toward file count)
692        self.check_write_limits(0)?;
693
694        // Remove any whiteout
695        self.remove_whiteout(&link);
696
697        // Create symlink in upper
698        self.upper.symlink(target, &link).await
699    }
700
701    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
702        let path = Self::normalize_path(path);
703
704        // Check for whiteout
705        if self.is_whiteout(&path) {
706            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
707        }
708
709        // Try upper first
710        if self.upper.exists(&path).await.unwrap_or(false) {
711            return self.upper.read_link(&path).await;
712        }
713
714        // Fall back to lower
715        self.lower.read_link(&path).await
716    }
717
718    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
719        let path = Self::normalize_path(path);
720
721        // Check for whiteout
722        if self.is_whiteout(&path) {
723            return Err(IoError::new(ErrorKind::NotFound, "not found").into());
724        }
725
726        // If exists in upper, chmod there
727        if self.upper.exists(&path).await.unwrap_or(false) {
728            return self.upper.chmod(&path, mode).await;
729        }
730
731        // If exists in lower, copy-on-write metadata
732        if self.lower.exists(&path).await.unwrap_or(false) {
733            let stat = self.lower.stat(&path).await?;
734
735            // Create in upper with same content (for files)
736            if stat.file_type == FileType::File {
737                let content = self.lower.read_file(&path).await?;
738                self.check_write_limits(content.len())?;
739
740                // Ensure parent dir exists in upper before write
741                if let Some(parent) = path.parent() {
742                    if !self.upper.exists(parent).await.unwrap_or(false) {
743                        self.upper.mkdir(parent, true).await?;
744                    }
745                }
746
747                self.upper.write_file(&path, &content).await?;
748                self.hide_lower_file(stat.size);
749            } else if stat.file_type == FileType::Directory {
750                self.upper.mkdir(&path, true).await?;
751                self.hide_lower_dir();
752            }
753
754            return self.upper.chmod(&path, mode).await;
755        }
756
757        Err(IoError::new(ErrorKind::NotFound, "not found").into())
758    }
759
760    fn usage(&self) -> FsUsage {
761        self.compute_usage()
762    }
763
764    fn limits(&self) -> FsLimits {
765        self.limits.clone()
766    }
767}
768
769#[cfg(test)]
770#[allow(clippy::unwrap_used)]
771mod tests {
772    use super::*;
773
774    #[tokio::test]
775    async fn test_read_from_lower() {
776        let lower = Arc::new(InMemoryFs::new());
777        lower
778            .write_file(Path::new("/tmp/test.txt"), b"hello")
779            .await
780            .unwrap();
781
782        let overlay = OverlayFs::new(lower);
783        let content = overlay.read_file(Path::new("/tmp/test.txt")).await.unwrap();
784        assert_eq!(content, b"hello");
785    }
786
787    #[tokio::test]
788    async fn test_write_to_upper() {
789        let lower = Arc::new(InMemoryFs::new());
790        let overlay = OverlayFs::new(lower.clone());
791
792        overlay
793            .write_file(Path::new("/tmp/new.txt"), b"new file")
794            .await
795            .unwrap();
796
797        // Should be readable from overlay
798        let content = overlay.read_file(Path::new("/tmp/new.txt")).await.unwrap();
799        assert_eq!(content, b"new file");
800
801        // Should NOT be in lower
802        assert!(!lower.exists(Path::new("/tmp/new.txt")).await.unwrap());
803    }
804
805    #[tokio::test]
806    async fn test_copy_on_write() {
807        let lower = Arc::new(InMemoryFs::new());
808        lower
809            .write_file(Path::new("/tmp/test.txt"), b"original")
810            .await
811            .unwrap();
812
813        let overlay = OverlayFs::new(lower.clone());
814
815        // Modify through overlay
816        overlay
817            .write_file(Path::new("/tmp/test.txt"), b"modified")
818            .await
819            .unwrap();
820
821        // Overlay should show modified
822        let content = overlay.read_file(Path::new("/tmp/test.txt")).await.unwrap();
823        assert_eq!(content, b"modified");
824
825        // Lower should still have original
826        let lower_content = lower.read_file(Path::new("/tmp/test.txt")).await.unwrap();
827        assert_eq!(lower_content, b"original");
828    }
829
830    #[tokio::test]
831    async fn test_delete_with_whiteout() {
832        let lower = Arc::new(InMemoryFs::new());
833        lower
834            .write_file(Path::new("/tmp/test.txt"), b"hello")
835            .await
836            .unwrap();
837
838        let overlay = OverlayFs::new(lower.clone());
839
840        // Delete through overlay
841        overlay
842            .remove(Path::new("/tmp/test.txt"), false)
843            .await
844            .unwrap();
845
846        // Should not be visible through overlay
847        assert!(!overlay.exists(Path::new("/tmp/test.txt")).await.unwrap());
848
849        // But should still exist in lower
850        assert!(lower.exists(Path::new("/tmp/test.txt")).await.unwrap());
851    }
852
853    #[tokio::test]
854    async fn test_recreate_after_delete() {
855        let lower = Arc::new(InMemoryFs::new());
856        lower
857            .write_file(Path::new("/tmp/test.txt"), b"original")
858            .await
859            .unwrap();
860
861        let overlay = OverlayFs::new(lower);
862
863        // Delete
864        overlay
865            .remove(Path::new("/tmp/test.txt"), false)
866            .await
867            .unwrap();
868        assert!(!overlay.exists(Path::new("/tmp/test.txt")).await.unwrap());
869
870        // Recreate
871        overlay
872            .write_file(Path::new("/tmp/test.txt"), b"new content")
873            .await
874            .unwrap();
875
876        // Should now exist with new content
877        assert!(overlay.exists(Path::new("/tmp/test.txt")).await.unwrap());
878        let content = overlay.read_file(Path::new("/tmp/test.txt")).await.unwrap();
879        assert_eq!(content, b"new content");
880    }
881
882    #[tokio::test]
883    async fn test_chmod_cow_enforces_write_limits() {
884        // Issue #417: chmod copy-on-write must check limits before writing to upper
885        let lower = Arc::new(InMemoryFs::new());
886        lower
887            .write_file(Path::new("/tmp/big.txt"), &vec![b'x'; 5000])
888            .await
889            .unwrap();
890
891        // Limit upper layer to 1000 bytes total - the 5000 byte file shouldn't fit
892        let limits = FsLimits::new().max_total_bytes(1000);
893        let overlay = OverlayFs::with_limits(lower, limits);
894
895        // chmod triggers CoW from lower -> upper; must be rejected
896        let result = overlay.chmod(Path::new("/tmp/big.txt"), 0o755).await;
897        assert!(
898            result.is_err(),
899            "chmod CoW should fail when content exceeds write limits"
900        );
901        let err = result.unwrap_err().to_string();
902        assert!(
903            err.contains("filesystem full"),
904            "expected 'filesystem full' error, got: {err}"
905        );
906
907        // File should NOT exist in upper layer
908        assert!(
909            !overlay
910                .upper
911                .exists(Path::new("/tmp/big.txt"))
912                .await
913                .unwrap(),
914            "file should not have been copied to upper layer"
915        );
916    }
917
918    #[tokio::test]
919    async fn test_usage_no_double_count_override() {
920        // Issue #418: overwriting a lower file in upper should not double-count
921        let lower = Arc::new(InMemoryFs::new());
922        lower
923            .write_file(Path::new("/tmp/file.txt"), b"lower data") // 10 bytes
924            .await
925            .unwrap();
926
927        let overlay = OverlayFs::new(lower);
928
929        // Snapshot before override
930        let usage_before = overlay.usage();
931
932        // Override in upper with smaller content
933        overlay
934            .write_file(Path::new("/tmp/file.txt"), b"upper!") // 6 bytes
935            .await
936            .unwrap();
937
938        let usage_after = overlay.usage();
939        // File count should not change: same file, just overridden
940        assert_eq!(
941            usage_after.file_count, usage_before.file_count,
942            "overridden file should not increase file_count"
943        );
944        // Bytes should decrease by (10 - 6) = 4 because lower's 10 bytes are
945        // replaced by upper's 6 bytes
946        assert_eq!(
947            usage_after.total_bytes,
948            usage_before.total_bytes - 4,
949            "overridden file bytes should reflect upper size, not sum"
950        );
951    }
952
953    #[tokio::test]
954    async fn test_usage_no_double_count_whiteout() {
955        // Issue #418: deleting a lower file should deduct it from usage
956        let lower = Arc::new(InMemoryFs::new());
957        lower
958            .write_file(Path::new("/tmp/gone.txt"), b"12345") // 5 bytes
959            .await
960            .unwrap();
961
962        let overlay = OverlayFs::new(lower.clone());
963        let usage_before = overlay.usage();
964
965        // Delete through overlay (creates whiteout)
966        overlay
967            .remove(Path::new("/tmp/gone.txt"), false)
968            .await
969            .unwrap();
970
971        let usage_after = overlay.usage();
972        assert_eq!(
973            usage_after.file_count,
974            usage_before.file_count - 1,
975            "whited-out file should not be counted"
976        );
977        assert_eq!(
978            usage_after.total_bytes,
979            usage_before.total_bytes - 5,
980            "whited-out file bytes should be deducted"
981        );
982    }
983
984    #[tokio::test]
985    async fn test_usage_unique_files_both_layers() {
986        // Files unique to each layer should each count once
987        let lower = Arc::new(InMemoryFs::new());
988        lower
989            .write_file(Path::new("/tmp/lower.txt"), b"aaa") // 3 bytes
990            .await
991            .unwrap();
992
993        let overlay = OverlayFs::new(lower);
994        let usage_before = overlay.usage();
995
996        overlay
997            .write_file(Path::new("/tmp/upper.txt"), b"bbbbb") // 5 bytes
998            .await
999            .unwrap();
1000
1001        let usage_after = overlay.usage();
1002        // Adding a unique upper file: +1 file, +5 bytes
1003        assert_eq!(
1004            usage_after.file_count,
1005            usage_before.file_count + 1,
1006            "unique upper file adds one to count"
1007        );
1008        assert_eq!(
1009            usage_after.total_bytes,
1010            usage_before.total_bytes + 5,
1011            "unique upper file adds its bytes"
1012        );
1013    }
1014
1015    #[tokio::test]
1016    async fn test_usage_recreate_after_whiteout() {
1017        // Delete then recreate: file should count once with new size
1018        let lower = Arc::new(InMemoryFs::new());
1019        lower
1020            .write_file(Path::new("/tmp/file.txt"), b"old data 10") // 11 bytes
1021            .await
1022            .unwrap();
1023
1024        let overlay = OverlayFs::new(lower);
1025        let usage_before = overlay.usage();
1026
1027        // Delete
1028        overlay
1029            .remove(Path::new("/tmp/file.txt"), false)
1030            .await
1031            .unwrap();
1032
1033        // Recreate with different size
1034        overlay
1035            .write_file(Path::new("/tmp/file.txt"), b"new") // 3 bytes
1036            .await
1037            .unwrap();
1038
1039        let usage_after = overlay.usage();
1040        // Net effect: replaced 11-byte file with 3-byte file => -8 bytes, same count
1041        assert_eq!(
1042            usage_after.file_count, usage_before.file_count,
1043            "recreated file counted once"
1044        );
1045        assert_eq!(
1046            usage_after.total_bytes,
1047            usage_before.total_bytes - 8,
1048            "recreated file uses new size"
1049        );
1050    }
1051
1052    #[tokio::test]
1053    async fn test_read_dir_merged() {
1054        let lower = Arc::new(InMemoryFs::new());
1055        lower
1056            .write_file(Path::new("/tmp/lower.txt"), b"lower")
1057            .await
1058            .unwrap();
1059
1060        let overlay = OverlayFs::new(lower);
1061        overlay
1062            .write_file(Path::new("/tmp/upper.txt"), b"upper")
1063            .await
1064            .unwrap();
1065
1066        let entries = overlay.read_dir(Path::new("/tmp")).await.unwrap();
1067        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
1068
1069        assert!(names.contains(&&"lower.txt".to_string()));
1070        assert!(names.contains(&&"upper.txt".to_string()));
1071    }
1072
1073    // Issue #418: usage should deduct whited-out files
1074    #[tokio::test]
1075    async fn test_usage_deducts_whiteouts() {
1076        let lower = Arc::new(InMemoryFs::new());
1077        lower
1078            .write_file(Path::new("/tmp/deleted.txt"), &[b'X'; 50])
1079            .await
1080            .unwrap();
1081
1082        let overlay = OverlayFs::new(lower);
1083        let before = overlay.usage();
1084
1085        overlay
1086            .remove(Path::new("/tmp/deleted.txt"), false)
1087            .await
1088            .unwrap();
1089
1090        let after = overlay.usage();
1091        assert_eq!(
1092            after.total_bytes,
1093            before.total_bytes - 50,
1094            "whited-out file bytes should be deducted"
1095        );
1096        assert_eq!(
1097            after.file_count,
1098            before.file_count - 1,
1099            "whited-out file should be deducted from count"
1100        );
1101    }
1102
1103    // Issue #418: append CoW should not double-count lower file
1104    #[tokio::test]
1105    async fn test_usage_no_double_count_append_cow() {
1106        let lower = Arc::new(InMemoryFs::new());
1107        lower
1108            .write_file(Path::new("/tmp/log.txt"), &[b'A'; 100])
1109            .await
1110            .unwrap();
1111
1112        let overlay = OverlayFs::new(lower);
1113        let before = overlay.usage();
1114
1115        overlay
1116            .append_file(Path::new("/tmp/log.txt"), &[b'B'; 10])
1117            .await
1118            .unwrap();
1119
1120        let after = overlay.usage();
1121        assert_eq!(
1122            after.total_bytes,
1123            before.total_bytes + 10,
1124            "CoW append should add only new content bytes"
1125        );
1126        assert_eq!(after.file_count, before.file_count);
1127    }
1128
1129    /// THREAT[TM-DOS-035]: Verify check_write_limits uses combined usage.
1130    #[tokio::test]
1131    async fn test_write_limits_include_lower_layer() {
1132        use super::super::limits::FsLimits;
1133
1134        let lower = Arc::new(InMemoryFs::new());
1135        // Write 80 bytes to lower
1136        lower
1137            .write_file(Path::new("/tmp/big.txt"), &[b'A'; 80])
1138            .await
1139            .unwrap();
1140
1141        // Create overlay with 100 byte total limit
1142        let limits = FsLimits::new().max_total_bytes(100);
1143        let overlay = OverlayFs::with_limits(lower, limits);
1144
1145        // Writing 30 bytes should fail: 80 (lower) + 30 (new) = 110 > 100
1146        let result = overlay
1147            .write_file(Path::new("/tmp/extra.txt"), &[b'B'; 30])
1148            .await;
1149        assert!(
1150            result.is_err(),
1151            "should reject write that exceeds combined limit"
1152        );
1153
1154        // Writing 15 bytes should succeed: 80 + 15 = 95 < 100
1155        let result = overlay
1156            .write_file(Path::new("/tmp/small.txt"), &[b'C'; 15])
1157            .await;
1158        assert!(result.is_ok(), "should allow write within combined limit");
1159    }
1160
1161    /// THREAT[TM-DOS-035]: Verify file count limit includes lower files.
1162    #[tokio::test]
1163    async fn test_file_count_limit_includes_lower() {
1164        use super::super::limits::FsLimits;
1165
1166        let lower = Arc::new(InMemoryFs::new());
1167        lower
1168            .write_file(Path::new("/tmp/existing.txt"), b"data")
1169            .await
1170            .unwrap();
1171
1172        // Get actual combined count (includes default entries from both layers)
1173        let temp_overlay = OverlayFs::new(lower.clone());
1174        let base_count = temp_overlay.usage().file_count;
1175
1176        // Set file count limit to base_count + 1
1177        let limits = FsLimits::new().max_file_count(base_count + 1);
1178        let overlay = OverlayFs::with_limits(lower, limits);
1179
1180        // First new file should succeed (base_count + 1 <= limit)
1181        overlay
1182            .write_file(Path::new("/tmp/new1.txt"), b"ok")
1183            .await
1184            .unwrap();
1185
1186        // Second new file should fail (base_count + 2 > limit)
1187        let result = overlay
1188            .write_file(Path::new("/tmp/new2.txt"), b"fail")
1189            .await;
1190        assert!(
1191            result.is_err(),
1192            "should reject when combined file count exceeds limit"
1193        );
1194    }
1195
1196    // Issue #420: recursive delete should whiteout child paths from lower layer
1197    #[tokio::test]
1198    async fn test_recursive_delete_whiteouts_children() {
1199        let lower = Arc::new(InMemoryFs::new());
1200        lower.mkdir(Path::new("/data"), true).await.unwrap();
1201        lower
1202            .write_file(Path::new("/data/a.txt"), b"aaa")
1203            .await
1204            .unwrap();
1205        lower
1206            .write_file(Path::new("/data/b.txt"), b"bbb")
1207            .await
1208            .unwrap();
1209        lower.mkdir(Path::new("/data/sub"), true).await.unwrap();
1210        lower
1211            .write_file(Path::new("/data/sub/c.txt"), b"ccc")
1212            .await
1213            .unwrap();
1214
1215        let overlay = OverlayFs::new(lower);
1216
1217        // rm -r /data
1218        overlay.remove(Path::new("/data"), true).await.unwrap();
1219
1220        // All children should be invisible
1221        assert!(
1222            !overlay.exists(Path::new("/data/a.txt")).await.unwrap(),
1223            "child file should be hidden after recursive delete"
1224        );
1225        assert!(
1226            !overlay.exists(Path::new("/data/sub/c.txt")).await.unwrap(),
1227            "nested child should be hidden after recursive delete"
1228        );
1229        assert!(
1230            !overlay.exists(Path::new("/data")).await.unwrap(),
1231            "directory itself should be hidden"
1232        );
1233
1234        // read_file should fail
1235        assert!(overlay.read_file(Path::new("/data/a.txt")).await.is_err());
1236    }
1237
1238    // Issue #420: usage should account for all recursively deleted lower files
1239    #[tokio::test]
1240    async fn test_recursive_delete_deducts_all_children() {
1241        let lower = Arc::new(InMemoryFs::new());
1242        lower.mkdir(Path::new("/stuff"), true).await.unwrap();
1243        lower
1244            .write_file(Path::new("/stuff/x.txt"), &[b'X'; 100])
1245            .await
1246            .unwrap();
1247        lower
1248            .write_file(Path::new("/stuff/y.txt"), &[b'Y'; 200])
1249            .await
1250            .unwrap();
1251
1252        let overlay = OverlayFs::new(lower);
1253        let before = overlay.usage();
1254
1255        overlay.remove(Path::new("/stuff"), true).await.unwrap();
1256
1257        let after = overlay.usage();
1258        assert_eq!(
1259            after.total_bytes,
1260            before.total_bytes - 300,
1261            "should deduct all child file bytes"
1262        );
1263        assert_eq!(
1264            after.file_count,
1265            before.file_count - 2,
1266            "should deduct all child file counts"
1267        );
1268    }
1269}