Skip to main content

hadris_fat/tool/
verify.rs

1//! Filesystem integrity verification for FAT filesystems.
2
3use alloc::collections::{BTreeMap, BTreeSet};
4use alloc::format;
5use alloc::string::{String, ToString};
6use alloc::vec::Vec;
7use core::ops::DerefMut;
8
9use super::super::{
10    dir::{DirectoryEntry, FatDir},
11    fs::FatVolume,
12    io::{Read, Seek},
13};
14use crate::error::Result;
15
16/// Types of verification issues that can be detected.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum VerificationIssue {
19    /// A cluster chain contains a loop (revisits a cluster).
20    ClusterLoop {
21        /// File or directory path containing the loop
22        path: String,
23        /// The cluster where the loop was detected
24        cluster: u32,
25    },
26
27    /// Two or more files share the same cluster (cross-linked).
28    CrossLinkedCluster {
29        /// The shared cluster number
30        cluster: u32,
31        /// Paths of files sharing this cluster
32        paths: Vec<String>,
33    },
34
35    /// A cluster chain exists in the FAT but is not referenced by any file.
36    OrphanedChain {
37        /// Starting cluster of the orphaned chain
38        start_cluster: u32,
39        /// Length of the chain in clusters
40        chain_length: u32,
41    },
42
43    /// The recorded file size doesn't match the cluster chain length.
44    SizeMismatch {
45        /// File path
46        path: String,
47        /// Size recorded in the directory entry
48        recorded_size: usize,
49        /// Size implied by the cluster chain
50        chain_size: usize,
51    },
52
53    /// A directory entry points to an invalid cluster.
54    InvalidFirstCluster {
55        /// File or directory path
56        path: String,
57        /// The invalid cluster number
58        cluster: u32,
59    },
60
61    /// The cluster chain contains a bad cluster marker.
62    BadClusterInChain {
63        /// File or directory path
64        path: String,
65        /// Position in the chain where the bad cluster was found
66        position: u32,
67        /// The bad cluster number
68        cluster: u32,
69    },
70
71    /// A directory entry has an invalid name.
72    InvalidEntryName {
73        /// Parent directory path
74        parent_path: String,
75        /// Raw bytes of the invalid name
76        raw_name: [u8; 11],
77    },
78
79    /// Lost clusters (used in FAT but not referenced by any file).
80    LostClusters {
81        /// Number of lost clusters
82        count: u32,
83    },
84}
85
86impl core::fmt::Display for VerificationIssue {
87    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
88        match self {
89            Self::ClusterLoop { path, cluster } => {
90                write!(f, "Cluster loop detected at cluster {cluster} in '{path}'")
91            }
92            Self::CrossLinkedCluster { cluster, paths } => {
93                write!(
94                    f,
95                    "Cross-linked cluster {}: shared by {}",
96                    cluster,
97                    paths.join(", ")
98                )
99            }
100            Self::OrphanedChain {
101                start_cluster,
102                chain_length,
103            } => {
104                write!(
105                    f,
106                    "Orphaned cluster chain starting at {start_cluster} ({chain_length} clusters)"
107                )
108            }
109            Self::SizeMismatch {
110                path,
111                recorded_size,
112                chain_size,
113            } => {
114                write!(
115                    f,
116                    "Size mismatch for '{path}': recorded {recorded_size} bytes, chain suggests {chain_size} bytes"
117                )
118            }
119            Self::InvalidFirstCluster { path, cluster } => {
120                write!(f, "Invalid first cluster {cluster} for '{path}'")
121            }
122            Self::BadClusterInChain {
123                path,
124                position,
125                cluster,
126            } => {
127                write!(
128                    f,
129                    "Bad cluster {cluster} at position {position} in chain for '{path}'"
130                )
131            }
132            Self::InvalidEntryName {
133                parent_path,
134                raw_name: _,
135            } => {
136                write!(f, "Invalid entry name in directory '{parent_path}'")
137            }
138            Self::LostClusters { count } => {
139                write!(f, "{count} lost clusters (not referenced by any file)")
140            }
141        }
142    }
143}
144
145/// Report from filesystem verification.
146#[derive(Debug, Clone)]
147pub struct VerificationReport {
148    /// List of issues found
149    pub issues: Vec<VerificationIssue>,
150    /// Total files checked
151    pub files_checked: u32,
152    /// Total directories checked
153    pub directories_checked: u32,
154    /// Total clusters verified
155    pub clusters_verified: u32,
156}
157
158impl VerificationReport {
159    /// Check if the filesystem passed verification (no issues found).
160    pub fn is_valid(&self) -> bool {
161        self.issues.is_empty()
162    }
163
164    /// Get the number of issues found.
165    pub fn issue_count(&self) -> usize {
166        self.issues.len()
167    }
168
169    /// Get issues filtered by type.
170    pub fn issues_of_type<F>(&self, predicate: F) -> Vec<&VerificationIssue>
171    where
172        F: Fn(&VerificationIssue) -> bool,
173    {
174        self.issues.iter().filter(|i| predicate(i)).collect()
175    }
176}
177
178/// Extension trait for FatVolume providing verification operations.
179pub trait FatVerifyExt<DATA: Read + Seek> {
180    /// Verify filesystem integrity.
181    ///
182    /// This performs comprehensive checks including:
183    /// - Cluster chain validation (loops, bad clusters)
184    /// - Cross-link detection (multiple files sharing clusters)
185    /// - Orphaned cluster detection
186    /// - File size validation
187    /// - Directory entry validation
188    fn verify(&self) -> Result<VerificationReport>;
189}
190
191impl<DATA: Read + Seek> FatVerifyExt<DATA> for FatVolume<DATA> {
192    fn verify(&self) -> Result<VerificationReport> {
193        let mut issues = Vec::new();
194        let mut files_checked = 0u32;
195        let mut directories_checked = 0u32;
196        let cluster_size = self.info.cluster_size;
197        let max_cluster = self.info.max_cluster;
198
199        // Map of cluster -> list of paths that reference it
200        let mut cluster_usage: BTreeMap<u32, Vec<String>> = BTreeMap::new();
201
202        // Track which clusters are used by files. A set, not a vec indexed
203        // by cluster: `max_cluster` derives from the untrusted BPB and a
204        // corrupt image could claim ~4 billion clusters, forcing a
205        // multi-gigabyte allocation on a tiny image.
206        let mut used_by_files: BTreeSet<u32> = BTreeSet::new();
207
208        // Verify all files and directories
209        self.verify_directory_recursive(
210            &self.root_dir(),
211            String::new(),
212            0,
213            &mut issues,
214            &mut files_checked,
215            &mut directories_checked,
216            &mut cluster_usage,
217            &mut used_by_files,
218            cluster_size,
219            max_cluster,
220        )?;
221
222        // Check for cross-linked clusters
223        for (cluster, paths) in &cluster_usage {
224            if paths.len() > 1 {
225                issues.push(VerificationIssue::CrossLinkedCluster {
226                    cluster: *cluster,
227                    paths: paths.clone(),
228                });
229            }
230        }
231
232        // Scan FAT for orphaned clusters (used in FAT but not by any file)
233        let mut data = self.data.lock();
234        let mut orphaned_count = 0u32;
235
236        for cluster in 2..=max_cluster {
237            if !used_by_files.contains(&cluster) {
238                // Check if this cluster is marked as used in the FAT
239                if let Ok(Some(_)) = self.fat.next_cluster(data.deref_mut(), cluster as usize) {
240                    orphaned_count += 1;
241                }
242            }
243        }
244
245        drop(data);
246
247        if orphaned_count > 0 {
248            issues.push(VerificationIssue::LostClusters {
249                count: orphaned_count,
250            });
251        }
252
253        Ok(VerificationReport {
254            issues,
255            files_checked,
256            directories_checked,
257            clusters_verified: max_cluster,
258        })
259    }
260}
261
262// Helper methods
263impl<DATA: Read + Seek> FatVolume<DATA> {
264    #[allow(clippy::too_many_arguments)]
265    fn verify_directory_recursive<'a>(
266        &'a self,
267        dir: &FatDir<'a, DATA>,
268        path_prefix: String,
269        depth: u32,
270        issues: &mut Vec<VerificationIssue>,
271        files_checked: &mut u32,
272        directories_checked: &mut u32,
273        cluster_usage: &mut BTreeMap<u32, Vec<String>>,
274        used_by_files: &mut BTreeSet<u32>,
275        cluster_size: usize,
276        max_cluster: u32,
277    ) -> Result<()> {
278        // A corrupt image can make a directory (transitively) contain
279        // itself; cap recursion depth so verification cannot overflow the
280        // stack. Matches the analysis walk's limit.
281        if depth > super::analysis::MAX_DIRECTORY_DEPTH {
282            return Err(crate::error::Error::CorruptFilesystem {
283                context: "directory nesting depth limit exceeded",
284            });
285        }
286        for entry in dir.entries() {
287            let entry = entry?;
288            let DirectoryEntry::Entry(file_entry) = entry;
289
290            let name = file_entry.name();
291            if name == "." || name == ".." {
292                continue;
293            }
294
295            let full_path = if path_prefix.is_empty() {
296                format!("/{name}")
297            } else {
298                format!("{path_prefix}/{name}")
299            };
300
301            let first_cluster = file_entry.cluster().0 as u32;
302
303            // Validate first cluster
304            if first_cluster != 0 && (first_cluster < 2 || first_cluster > max_cluster) {
305                issues.push(VerificationIssue::InvalidFirstCluster {
306                    path: full_path.clone(),
307                    cluster: first_cluster,
308                });
309                continue;
310            }
311
312            if file_entry.is_directory() {
313                *directories_checked += 1;
314
315                // Verify directory cluster chain
316                if first_cluster >= 2 {
317                    self.verify_cluster_chain(
318                        first_cluster,
319                        &full_path,
320                        issues,
321                        cluster_usage,
322                        used_by_files,
323                        max_cluster,
324                    )?;
325                }
326
327                // Recurse into subdirectory
328                let subdir = FatDir {
329                    data: self,
330                    cluster: file_entry.cluster(),
331                    fixed_root: None,
332                    #[cfg(feature = "write")]
333                    dir_entry: None, // Read-only traversal.
334                };
335                self.verify_directory_recursive(
336                    &subdir,
337                    full_path,
338                    depth + 1,
339                    issues,
340                    files_checked,
341                    directories_checked,
342                    cluster_usage,
343                    used_by_files,
344                    cluster_size,
345                    max_cluster,
346                )?;
347            } else {
348                *files_checked += 1;
349
350                // Verify file cluster chain
351                if first_cluster >= 2 {
352                    let chain_length = self.verify_cluster_chain(
353                        first_cluster,
354                        &full_path,
355                        issues,
356                        cluster_usage,
357                        used_by_files,
358                        max_cluster,
359                    )?;
360
361                    // Verify file size matches chain length
362                    let recorded_size = file_entry.len() as usize;
363                    let chain_size = chain_length as usize * cluster_size;
364                    let min_chain_size = if chain_length > 0 {
365                        (chain_length as usize - 1) * cluster_size + 1
366                    } else {
367                        0
368                    };
369
370                    if recorded_size > chain_size
371                        || (recorded_size > 0 && recorded_size < min_chain_size)
372                    {
373                        issues.push(VerificationIssue::SizeMismatch {
374                            path: full_path,
375                            recorded_size,
376                            chain_size,
377                        });
378                    }
379                } else if !file_entry.is_empty() {
380                    // Non-zero size but no cluster chain
381                    issues.push(VerificationIssue::SizeMismatch {
382                        path: full_path,
383                        recorded_size: file_entry.len() as usize,
384                        chain_size: 0,
385                    });
386                }
387            }
388        }
389
390        Ok(())
391    }
392
393    fn verify_cluster_chain(
394        &self,
395        start_cluster: u32,
396        path: &str,
397        issues: &mut Vec<VerificationIssue>,
398        cluster_usage: &mut BTreeMap<u32, Vec<String>>,
399        used_by_files: &mut BTreeSet<u32>,
400        max_cluster: u32,
401    ) -> Result<u32> {
402        let mut chain_length = 0u32;
403        let mut current = start_cluster;
404        let mut data = self.data.lock();
405
406        // Track visited clusters to detect loops
407        let mut visited = alloc::vec![false; max_cluster as usize + 1];
408
409        let max_iterations = max_cluster as usize;
410        let mut iterations = 0;
411
412        loop {
413            if current < 2 || current > max_cluster {
414                break;
415            }
416
417            // Check for loop
418            if visited[current as usize] {
419                issues.push(VerificationIssue::ClusterLoop {
420                    path: path.to_string(),
421                    cluster: current,
422                });
423                break;
424            }
425
426            visited[current as usize] = true;
427            used_by_files.insert(current);
428            chain_length += 1;
429
430            // Record cluster usage
431            cluster_usage
432                .entry(current)
433                .or_default()
434                .push(path.to_string());
435
436            iterations += 1;
437            if iterations > max_iterations {
438                // Safety limit to prevent infinite loops
439                break;
440            }
441
442            // Get next cluster
443            match self.fat.next_cluster(data.deref_mut(), current as usize)? {
444                Some(next) => {
445                    current = next;
446                }
447                None => break, // End of chain
448            }
449        }
450
451        Ok(chain_length)
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_verification_report_is_valid() {
461        let report = VerificationReport {
462            issues: Vec::new(),
463            files_checked: 10,
464            directories_checked: 5,
465            clusters_verified: 1000,
466        };
467        assert!(report.is_valid());
468
469        let report_with_issues = VerificationReport {
470            issues: alloc::vec![VerificationIssue::LostClusters { count: 5 }],
471            files_checked: 10,
472            directories_checked: 5,
473            clusters_verified: 1000,
474        };
475        assert!(!report_with_issues.is_valid());
476    }
477}