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                };
333                self.verify_directory_recursive(
334                    &subdir,
335                    full_path,
336                    depth + 1,
337                    issues,
338                    files_checked,
339                    directories_checked,
340                    cluster_usage,
341                    used_by_files,
342                    cluster_size,
343                    max_cluster,
344                )?;
345            } else {
346                *files_checked += 1;
347
348                // Verify file cluster chain
349                if first_cluster >= 2 {
350                    let chain_length = self.verify_cluster_chain(
351                        first_cluster,
352                        &full_path,
353                        issues,
354                        cluster_usage,
355                        used_by_files,
356                        max_cluster,
357                    )?;
358
359                    // Verify file size matches chain length
360                    let recorded_size = file_entry.len() as usize;
361                    let chain_size = chain_length as usize * cluster_size;
362                    let min_chain_size = if chain_length > 0 {
363                        (chain_length as usize - 1) * cluster_size + 1
364                    } else {
365                        0
366                    };
367
368                    if recorded_size > chain_size
369                        || (recorded_size > 0 && recorded_size < min_chain_size)
370                    {
371                        issues.push(VerificationIssue::SizeMismatch {
372                            path: full_path,
373                            recorded_size,
374                            chain_size,
375                        });
376                    }
377                } else if !file_entry.is_empty() {
378                    // Non-zero size but no cluster chain
379                    issues.push(VerificationIssue::SizeMismatch {
380                        path: full_path,
381                        recorded_size: file_entry.len() as usize,
382                        chain_size: 0,
383                    });
384                }
385            }
386        }
387
388        Ok(())
389    }
390
391    fn verify_cluster_chain(
392        &self,
393        start_cluster: u32,
394        path: &str,
395        issues: &mut Vec<VerificationIssue>,
396        cluster_usage: &mut BTreeMap<u32, Vec<String>>,
397        used_by_files: &mut BTreeSet<u32>,
398        max_cluster: u32,
399    ) -> Result<u32> {
400        let mut chain_length = 0u32;
401        let mut current = start_cluster;
402        let mut data = self.data.lock();
403
404        // Track visited clusters to detect loops
405        let mut visited = alloc::vec![false; max_cluster as usize + 1];
406
407        let max_iterations = max_cluster as usize;
408        let mut iterations = 0;
409
410        loop {
411            if current < 2 || current > max_cluster {
412                break;
413            }
414
415            // Check for loop
416            if visited[current as usize] {
417                issues.push(VerificationIssue::ClusterLoop {
418                    path: path.to_string(),
419                    cluster: current,
420                });
421                break;
422            }
423
424            visited[current as usize] = true;
425            used_by_files.insert(current);
426            chain_length += 1;
427
428            // Record cluster usage
429            cluster_usage
430                .entry(current)
431                .or_default()
432                .push(path.to_string());
433
434            iterations += 1;
435            if iterations > max_iterations {
436                // Safety limit to prevent infinite loops
437                break;
438            }
439
440            // Get next cluster
441            match self.fat.next_cluster(data.deref_mut(), current as usize)? {
442                Some(next) => {
443                    current = next;
444                }
445                None => break, // End of chain
446            }
447        }
448
449        Ok(chain_length)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_verification_report_is_valid() {
459        let report = VerificationReport {
460            issues: Vec::new(),
461            files_checked: 10,
462            directories_checked: 5,
463            clusters_verified: 1000,
464        };
465        assert!(report.is_valid());
466
467        let report_with_issues = VerificationReport {
468            issues: alloc::vec![VerificationIssue::LostClusters { count: 5 }],
469            files_checked: 10,
470            directories_checked: 5,
471            clusters_verified: 1000,
472        };
473        assert!(!report_with_issues.is_valid());
474    }
475}