Skip to main content

irox_tools/fs/
mod.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2025 IROX Contributors
3//
4
5//!
6//! Filesystem utilities
7
8use crate::{cfg_feature_alloc, cfg_feature_std};
9use core::fmt::{Display, Formatter};
10
11cfg_feature_std! {
12    mod temp;
13    pub use temp::*;
14}
15
16///
17/// A list of characters that are usually prohibited by common filesystems like VFAT and NTFS.
18/// See [Wikipedia:Filename](https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words)
19pub static USUALLY_PROHIBITED_FS_CHARS: &[char; 9] =
20    &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
21
22///
23/// A list of characters that are prohibited by FAT12, FAT16, FAT34
24/// See [Wikipedia:Filename](https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words)
25pub static FATXX_PROHIBITED_FS_CHARS: &[char; 18] = &[
26    '"', '*', '/', ':', '<', '>', '?', '\\', '|', '+', ',', '.', ';', '=', '[', ']', '!', '@',
27];
28
29///
30/// A list of filenames prohibited by windows.  
31/// See [Wikipedia:Filename](https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words)
32pub static WINDOWS_PROHIBITED_FILE_NAMES: &[&str; 45] = &[
33    "CON", "PRN", "AUX", "CLOCK$", "LST", "KEYBD$", "SCREEN$", "$IDLE$", "CONFIG$", "NUL", "COM0",
34    "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2",
35    "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "$Mft", "$MftMirr", "$LogFile",
36    "$Volume", "$AttrDef", "$Bitmap", "$Boot", "$BadClus", "$Secure", "$Upcase", "$Extend",
37    "$Quota", "$ObjId", "$Reparse", "$Extend",
38];
39
40///
41/// A list of characters that are usually safe for use in filesystem names.  This is essentially the
42/// printable characters minus [`USUALLY_PROHIBITED_FS_CHARS`] and [`;`,`$`] (to avoid shell issues)
43/// See [Wikipedia:Filename](https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words)
44pub static USUALLY_SAFE_FS_CHARS: &[char; 83] = &[
45    ' ', '!', '#', '%', '&', '(', ')', '+', ',', '-', '.', '0', '1', '2', '3', '4', '5', '6', '7',
46    '8', '9', '=', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
47    'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', ']', '^', '_', '`', 'a', 'b', 'c',
48    'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
49    'w', 'x', 'y', 'z', '{', '}', '~',
50];
51
52///
53/// An error case returned from the filename checker
54#[derive(Debug, Copy, Clone, Eq, PartialEq)]
55pub enum FilenameError {
56    StartsWithWindowsProhibited(&'static str),
57    ContainsUsuallyInvalidChar(char),
58    EndsWithInvalidCharacter(char),
59}
60impl Display for FilenameError {
61    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
62        match self {
63            FilenameError::StartsWithWindowsProhibited(pro) => {
64                write!(f, "Filename starts with windows prohibited word: {pro}")
65            }
66            FilenameError::ContainsUsuallyInvalidChar(chr) => {
67                write!(
68                    f,
69                    "Filename contains a usually invalid character: {chr}(0x{:02X})",
70                    *chr as u16
71                )
72            }
73            FilenameError::EndsWithInvalidCharacter(chr) => {
74                write!(
75                    f,
76                    "Filename ends with a usually invalid character: {chr}(0x{:02X})",
77                    *chr as u16
78                )
79            }
80        }
81    }
82}
83impl core::error::Error for FilenameError {}
84
85cfg_feature_alloc! {
86    extern crate alloc;
87    ///
88    /// Removes any character in the input value that isn't in [`USUALLY_SAFE_FS_CHARS`]
89    pub fn clean_filename<T: AsRef<str>>(val: &T) -> alloc::string::String {
90        let input = val.as_ref();
91        let mut out = alloc::string::String::with_capacity(input.len());
92        for v in input.chars() {
93            if USUALLY_SAFE_FS_CHARS.binary_search(&v).is_ok() {
94                out.push(v);
95            }
96        }
97        out
98    }
99}
100
101///
102/// Checks the provided filename against the set of [`WINDOWS_PROHIBITED_FILE_NAMES`] and
103/// [`USUALLY_PROHIBITED_FS_CHARS`], returning an error if either set of checks are found
104pub fn is_filename_probably_valid<T: AsRef<str>>(val: &T) -> Result<(), FilenameError> {
105    let input = val.as_ref();
106    for invalid in WINDOWS_PROHIBITED_FILE_NAMES {
107        if input.starts_with(invalid) {
108            return Err(FilenameError::StartsWithWindowsProhibited(invalid));
109        }
110    }
111    for v in input.chars() {
112        let vi = v as u32;
113        if !(0x20..=0x7E).contains(&vi) || USUALLY_PROHIBITED_FS_CHARS.binary_search(&v).is_ok() {
114            return Err(FilenameError::ContainsUsuallyInvalidChar(v));
115        }
116    }
117    Ok(())
118}
119
120cfg_feature_std! {
121    use std::path::{Path, PathBuf};
122    use std::fs::{DirEntry};
123    use irox_bits::{Error, ErrorKind};
124
125    ///
126    /// Recursively finds all files with the associated extension in the starting directory
127    pub fn find_all_files_with_extension<T: AsRef<Path>>(start: T, ext: &str) -> Result<Vec<PathBuf>, Error> {
128        let mut out = Vec::new();
129        let path = start.as_ref();
130        let ent = std::fs::read_dir(path)?;
131        for dirent in ent {
132            let dirent = dirent?;
133            collect_fileswithext_in_dirent(&dirent, &mut out, ext)?;
134        }
135        Ok(out)
136    }
137
138    fn collect_fileswithext_in_dirent(
139        dirent: &DirEntry,
140        out: &mut Vec<PathBuf>,
141        _ext: &str,
142    ) -> Result<(), Error> {
143        let path = dirent.path();
144        if path.is_dir() {
145            let ent = std::fs::read_dir(path)?;
146            for dirent in ent {
147                let dirent = dirent?;
148                collect_fileswithext_in_dirent(&dirent, out, _ext)?;
149            }
150        } else if let Some(ext) = path.extension() {
151            if ext.to_string_lossy().to_lowercase().as_str() == ext {
152                out.push(path);
153            }
154        }
155
156        Ok(())
157    }
158
159    ///
160    /// Finds
161    pub fn find_associated_file<T: AsRef<Path>>(file: T, newext: &str) -> Result<PathBuf, Error> {
162        let path = file.as_ref();
163        let dir = path.parent().unwrap_or_else(|| Path::new("."));
164        // check concat first.
165        let Some(filename) = path.file_name() else {
166            return Error::err(ErrorKind::NotFound, "Provided file doesn't have a name");
167        };
168        let filename = format!("{}.{newext}", filename.to_string_lossy());
169        let mut appended = path.to_path_buf();
170        appended.set_file_name(filename);
171
172        if appended.exists() && appended.is_file() {
173            return Ok(appended);
174        }
175        // check replacement next.
176        let Some(stem) = path.file_stem() else {
177            return Error::err(ErrorKind::NotFound, "Provided file doesn't have a stem");
178        };
179        Ok(dir.join(format!("{}.{}", stem.to_string_lossy(), newext)))
180    }
181}