Coverage Report

Created: 2026-05-25 08:50

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/MathCAT/MathCAT/src/shim_filesystem.rs
Line
Count
Source
1
#![allow(clippy::needless_return)]
2
//! This is used to paste over normal reading of the Rules files and building them into the code for web assembly (WASM) which
3
//! can't do file system access. For the latter, the Rules directory is zipped up.
4
5
use std::path::{Path, PathBuf};
6
use crate::errors::*;
7
use cfg_if::cfg_if;
8
9
#[allow(unused_imports)]
10
use log::{debug};
11
12
13
// The zipped files are needed by WASM builds.
14
// However, they are also useful for other builds because there really isn't another good way to get at the rules.
15
// Other build scripts can extract these files and unzip to their needed locations.
16
// I'm not thrilled with this solution as it seems hacky, but I don't know another way for crates to allow for each access to data.
17
cfg_if! {
18
    if #[cfg(any(target_family = "wasm", feature = "include-zip"))] {
19
        // For the include-zip builds, we build a fake file system based on ZIPPED_RULE_FILES.
20
        // That stream encodes other zip files that must be unzipped.
21
        // Only one level of embedded zip files is supported.
22
        use zip::ZipArchive;
23
        pub static ZIPPED_RULE_FILES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"),"/rules.zip"));
24
25
        /// Struct to indicate where a file is located in the zip archive(s)
26
        #[derive(Debug, Copy, Clone)]
27
        struct ArchivePath {
28
            main: usize,                // index into ZIPPED_RULE_FILES
29
            zipped: Option<usize>,      // if Some, index into embedded zip file, None if top-level zip file
30
        }
31
32
        use std::cell::RefCell;
33
        use std::io::Cursor;
34
        use std::io::Read;
35
        use std::collections::{HashMap, HashSet};
36
        thread_local! {
37
            // mapping the file names to whether they are a directory or a file
38
            // Note: these are always stored with "/" as the path separator
39
            static DIRECTORIES: RefCell<HashSet<String>> = RefCell::new(HashSet::with_capacity(127));
40
            // if a file, we note whether it is in ZIPPED_RULE_FILES or the index of a zipped file within ZIPPED_RULE_FILES
41
            static FILES: RefCell<HashMap<String, ArchivePath>> = RefCell::new(HashMap::with_capacity(1023));
42
        }
43
44
        /// Canonicalize path separators to "/"
45
        fn canonicalize_path_separators(path: &Path) -> String {
46
            return path.to_str().unwrap_or_default().replace("\\", "/");
47
        }
48
        
49
        /// Return a zip archive given the zip bytes
50
        fn get_zip_archive(zip_bytes: &[u8]) -> Result<ZipArchive<Cursor<&[u8]>>> {
51
            let buf_reader = Cursor::new(zip_bytes);
52
            let archive = match zip::ZipArchive::new(buf_reader) {
53
                Err(e) => bail!("get_zip_archive: failed to create ZipArchive: {}", e),
54
                Ok(archive) => archive,
55
            };
56
            return Ok(archive);
57
        }
58
59
        /// Read ZIPPED_RULE_FILES and build up the FILES and DIRECTORIES static variables.
60
        /// This is called lazily when the first file or directory check is done.
61
        fn initialize_static_vars() -> Result<()> {
62
            let mut archive = get_zip_archive(ZIPPED_RULE_FILES)?;
63
            read_zip_file("", &mut archive, None)?;
64
65
            // Because of Rust's borrow checker, we can't recursively unzip contained zip files (FILES, etc., are borrowed mut)
66
            // Here we gather up the zip files that were found and iterate over them non-recursively.
67
            // Note: there shouldn't be embedded zip files in these files (if there are, they won't be unzipped)
68
            let zip_files = FILES.with(|files| files.borrow().iter()
69
                .filter_map(|(name, archive_path)| if name.ends_with(".zip") { Some((name.clone(), *archive_path)) } else { None } )
70
                .collect::<Vec<_>>()
71
            );
72
            // debug!("Found {:?} embedded zip files", zip_files);
73
            for (zip_file_name, archive_path) in zip_files.iter() {
74
                let bytes = get_bytes_from_index(&mut archive, archive_path.main)?;
75
                let mut inner_archive = get_zip_archive(bytes.as_slice())?;
76
                // debug!("  internal zip file {} has {} files", zip_file_name, inner_archive.len());
77
                let new_containing_dir = zip_file_name.rsplit_once("/").map(|(before, _)| before).unwrap_or("");
78
                read_zip_file(new_containing_dir, &mut inner_archive, Some(archive_path.main))?;
79
            }
80
            // FILES.with(|files| {
81
            //     let files = files.borrow();
82
            //     debug!("{} files={:?}", files.len(), files);
83
            // });
84
            return Ok(());
85
        }
86
87
        /// Get the bytes for a file in the zip archive (intended for embedded zip files)
88
        fn get_bytes_from_index(archive: &mut ZipArchive<Cursor<&[u8]>>, index: usize) -> Result<Vec<u8>> {
89
            let mut file = archive.by_index(index)
90
                .map_err(|e| anyhow!(format!("Error getting index={} from zip archive: {}", index, e)) )?;
91
            let mut contents = Vec::new();
92
            file.read_to_end(&mut contents)
93
                .map_err(|e| anyhow!(format!("Error reading index={} from zip archive: {}", index, e)) )?;
94
            return Ok(contents);
95
        }
96
        /// Unzip the zip file (given by zip_archive) and record the file and dir names
97
        /// 'containing_dir' is the rule dir (RulesDir or a subdir) and establishes a full path for unzipped file(s)
98
        /// embedded_zip_file is index into ZIPPED_RULE_FILES if this is an embedded zip file, None if it is the top-level zip file
99
        fn read_zip_file(containing_dir: &str, zip_archive: &mut ZipArchive<Cursor<&[u8]>>, embedded_zip_file: Option<usize>) -> Result<()> {
100
            // debug!("read_zip_file: containing_dir='{}', zip_archive.len()={}", containing_dir, zip_archive.len());
101
            return FILES.with(|files| {
102
                let mut files = files.borrow_mut();
103
                return DIRECTORIES.with(|dirs| {
104
                    let mut dirs = dirs.borrow_mut();
105
                    for i in 0..zip_archive.len() {
106
                        let file = zip_archive.by_index(i).unwrap();
107
                        // A little bit of safety/sanity checking
108
                        let path = match file.enclosed_name() {
109
                            Some(path) => PathBuf::from(containing_dir).join(path),
110
                            None => {
111
                                bail!("Entry {} has a suspicious path (outside of archive)", file.name());
112
                            }
113
                        };
114
                        // debug!("read_zip_file: file path='{}'", path.display());
115
                        // add all the dirs up to the containing dir -- skip the first one as that is a file
116
                        // for files like unicode.yaml, this loop is a no-op, but for files in the Shared folder, it will go one time.
117
                        for parent in path.ancestors().skip(1) {
118
                            if parent.to_str().unwrap_or_default() == containing_dir {
119
                                break;
120
                            }
121
                            dirs.insert(canonicalize_path_separators(parent));
122
                        }
123
                        let file_name = canonicalize_path_separators(&path);
124
                        if file.is_file() {
125
                            let archive_path = match embedded_zip_file {
126
                                None => ArchivePath{ main: i, zipped: None },
127
                                Some(main) => ArchivePath{ main, zipped: Some(i) },
128
                            };
129
                            files.insert(file_name, archive_path);
130
                        } else if file.is_dir() {
131
                            dirs.insert(file_name);
132
                        } else {
133
                            bail!("read_zip_file: {} is neither a file nor a directory", path.display());
134
                        }
135
                    };
136
                    // debug!("{} files={:?}", files.len(), files);
137
                    // debug!("{} dirs={:?}", dirs.len(), dirs);
138
                    return Ok::<(), Error>( () );
139
                });
140
            });
141
        }
142
143
        pub fn is_file_shim(path: &Path) -> bool {
144
            if FILES.with(|files| files.borrow().is_empty()) {
145
                let _ignore_result = initialize_static_vars();
146
            }
147
            return FILES.with(|files| files.borrow().contains_key(&canonicalize_path_separators(path)) );
148
        }
149
        
150
        pub fn is_dir_shim(path: &Path) -> bool {
151
            if FILES.with(|files| files.borrow().is_empty()) {
152
                let _ignore_result = initialize_static_vars();
153
            }
154
            return DIRECTORIES.with(|dirs| dirs.borrow().contains(&canonicalize_path_separators(path)) );
155
        }
156
157
        /// Find files in 'dir' that end with 'ending' (e.g., "_Rules.yaml")
158
        pub fn find_files_in_dir_that_ends_with_shim(dir: &Path, ending: &str) -> Vec<String> {
159
            // FIX: this is very inefficient because it looks through all the files -- maybe dirs should list the files in them?
160
            // look for files that have 'path' as a prefix
161
            return FILES.with(|files| {
162
                let files = files.borrow();
163
                let mut answer = Vec::new();
164
165
                let dir_name = canonicalize_path_separators(dir);
166
                for file_name in files.keys() {
167
                    if let Some(dir_relative_name) = file_name.strip_prefix(&dir_name) &&
168
                       file_name.ends_with(ending)
169
                    {
170
                        // this could be (e.g.) xxx_Rules.yaml or it could be subdir/xxx_Rules.yaml
171
                        let file_name = dir_relative_name.split_once("/").map(|(_, after)| after).unwrap_or(dir_relative_name);
172
                        answer.push(file_name.to_string());
173
                    }
174
                }
175
                // debug!("find_files_in_dir_that_ends_with_shim: in dir '{}' found {:?}", dir.display(), answer);
176
                return answer;
177
            });
178
        }
179
        
180
181
        pub fn find_all_dirs_shim(dir: &Path, found_dirs: &mut Vec<PathBuf> ) {
182
            return DIRECTORIES.with(|dirs| {
183
                let dirs = dirs.borrow();
184
185
                let common_dir_name = canonicalize_path_separators(dir);
186
                for dir_name in dirs.iter() {
187
                    if dir_name.starts_with(&common_dir_name) && !dir_name.contains("SharedRules") {
188
                        found_dirs.push(PathBuf::from(&dir_name));
189
                    };
190
                }
191
            });
192
        }
193
194
        
195
        pub fn canonicalize_shim(path: &Path) -> std::io::Result<PathBuf> {
196
            use std::ffi::OsStr;
197
            let dot_dot = OsStr::new("..");
198
            let mut result = PathBuf::new();
199
            for part in path.iter() {
200
                if dot_dot == part {
201
                    result.pop();
202
                } else {
203
                    result.push(part);
204
                }
205
            }
206
            return Ok(result);
207
        }
208
        
209
        /// Read the file at 'path' and return its contents as a String
210
        pub fn read_to_string_shim(path: &Path) -> Result<String> {
211
            let path = canonicalize_shim(path).unwrap();        // can't fail
212
            let file_name = canonicalize_path_separators(&path);
213
            // Is this the debugging override?
214
            if let Some(contents) = OVERRIDE_FILE_NAME.with(|override_name| {
215
                if file_name == override_name.borrow().as_str() {
216
                    // debug!("override read_to_string_shim: {}",file_name);
217
                    return OVERRIDE_FILE_CONTENTS.with(|contents| return Some(contents.borrow().clone()));
218
                } else {
219
                    return None;
220
                }
221
            }) {
222
                return Ok(contents);
223
            };
224
225
            let file_name = file_name.replace('\\', "/"); // zip files always use forward slash
226
            // top-level zip file or embedded zip file
227
            return FILES.with(|files| {
228
                let files = files.borrow();
229
                let inner_bytes;
230
                let (bytes, index) = match files.get(&file_name) {
231
                    Some(archive_path) => {
232
                        match &archive_path.zipped {
233
                            None => (ZIPPED_RULE_FILES, archive_path.main),
234
                            Some(i) => {
235
                                // debug!("read_to_string_shim: reading embedded zip file {} at index {}", file_name, *i);
236
                                let mut archive = get_zip_archive(ZIPPED_RULE_FILES)?;
237
                                inner_bytes = get_bytes_from_index(&mut archive, archive_path.main)?;  // need to hold temp value
238
                                (inner_bytes.as_slice(), *i)
239
                            }
240
                        }
241
                    },
242
                    None => bail!("read_to_string_shim: didn't find {} in zip archive", file_name),
243
                };
244
                let mut archive = get_zip_archive(bytes)?;
245
                let mut file = match archive.by_index(index) {
246
                    Ok(file) => {
247
                        // debug!("read_to_string_shim: want {}; name of zipped file={:?}", file_name, file.enclosed_name().unwrap());
248
                        file
249
                    },
250
                    Err(..) => {
251
                        bail!("Didn't find {} in zip archive", file_name);
252
                    }
253
                };
254
255
                let mut contents = String::new();
256
                if let Err(e) = file.read_to_string(&mut contents) {
257
                    bail!("read_to_string: {}", e);
258
                }
259
                return Ok(contents);
260
            });
261
        }
262
263
        pub fn zip_extract_shim(dir: &Path, zip_file_name: &str) -> Result<bool> {
264
            let zip_file_path = dir.join(zip_file_name);
265
            let full_zip_file_name = canonicalize_path_separators(&zip_file_path);
266
            match FILES.with(|files| files.borrow().contains_key(full_zip_file_name.as_str()) ) {
267
                true => Ok(true),
268
                false => bail!("zip_extract_shim: didn't find {} in zip archive", full_zip_file_name),
269
            }
270
        }
271
272
        thread_local! {
273
            // For debugging rules files (mainly nav file) via MathCATDemo
274
            static OVERRIDE_FILE_NAME: RefCell<String> = RefCell::new("".to_string());
275
            static OVERRIDE_FILE_CONTENTS: RefCell<String> = RefCell::new("".to_string());
276
        }
277
        pub fn override_file_for_debugging_rules(file_name: &str, file_contents: &str) {
278
            // file_name should be path name starting at Rules dir: e.g, "Rules/en/navigate.yaml"
279
            OVERRIDE_FILE_NAME.with(|name| *name.borrow_mut() = file_name.to_string().replace("/", "\\"));
280
            OVERRIDE_FILE_CONTENTS.with(|contents| *contents.borrow_mut() = file_contents.to_string());
281
            crate::interface::set_rules_dir("Rules").unwrap();       // force reinitialization after the change
282
        }
283
    } else {
284
116k
        pub fn is_file_shim(path: &Path) -> bool {
285
116k
            return path.is_file();
286
116k
        }
287
        
288
135k
        pub fn is_dir_shim(path: &Path) -> bool {
289
135k
            return path.is_dir();
290
135k
        }
291
        
292
12.9k
        pub fn find_files_in_dir_that_ends_with_shim(dir: &Path, ending: &str) ->  Vec<String> {
293
12.9k
            match dir.read_dir() {
294
0
                Err(_) => return vec![],    // empty
295
12.9k
                Ok(read_dir) => {
296
12.9k
                    let mut answer = Vec::new();
297
78.5k
                    for dir_entry in 
read_dir12.9k
.
flatten12.9k
() {
298
78.5k
                        let file_name = dir_entry.file_name();
299
78.5k
                        let file_name = file_name.to_string_lossy().to_string();
300
78.5k
                        if file_name.ends_with(ending) {
301
                            // this could be (e.g.) xxx_Rules.yaml or it could be subdir/xxx_Rules.yaml
302
64.4k
                            let file_name = file_name.split_once(std::path::MAIN_SEPARATOR).map(|(_, after)| after).unwrap_or(&file_name);
303
64.4k
                            answer.push( file_name.to_string() );
304
14.0k
                        }
305
                    }
306
12.9k
                    return answer;
307
                }
308
            }
309
12.9k
        }
310
311
2.90k
        pub fn find_all_dirs_shim(dir: &Path, found_dirs: &mut Vec<PathBuf> ) {
312
            // FIX: this doesn't work for subdirectories that haven't been unzipped yet
313
2.90k
            assert!(dir.is_dir(), "find_all_dirs_shim called with non-directory path: {}", 
dir0
.
display0
());
314
2.90k
            let mut found_rules_file = false;
315
2.90k
            if let Ok(entries) = std::fs::read_dir(dir) {
316
13.0k
                for entry in 
entries2.90k
.
flatten2.90k
() {
317
13.0k
                    let path = entry.path();
318
13.0k
                    if path.is_dir() {
319
                        // skip "SharedRules" directory
320
2.90k
                        if let Some(dir_name) = path.file_name() &&
321
2.90k
                           dir_name.to_str().unwrap_or_default() != "SharedRules" {
322
1.45k
                            find_all_dirs_shim(&path, found_dirs);
323
1.45k
                        
}1.44k
324
                    } else {
325
10.1k
                        let file_name = path.file_name().unwrap_or_default().to_str().unwrap_or_default();
326
10.1k
                        if !found_rules_file &&
327
1.46k
                           (file_name.starts_with("unicode") || 
file_name1.45k
.
starts_with1.45k
("definitions") ||
file_name0
.
ends_with0
("_Rules.yaml") ||
file_name0
.
ends_with0
(".zip")) {
328
1.46k
                            found_dirs.push(path.parent().unwrap().to_path_buf());
329
                            // FIX: hack to get around not unzipping files and having zh/tw not found
330
1.46k
                            if file_name == "zh.zip" {
331
0
                                let tw_dir = path.parent().unwrap().join("tw");
332
0
                                if !found_dirs.contains(&tw_dir) {
333
0
                                    found_dirs.push(tw_dir.to_path_buf());
334
0
                                }
335
1.46k
                            }
336
1.46k
                            found_rules_file = true;
337
8.71k
                        }
338
                    }
339
                }
340
0
            }
341
2.90k
        }
342
        
343
73.2k
        pub fn canonicalize_shim(path: &Path) -> std::io::Result<PathBuf> {
344
73.2k
            return path.canonicalize();
345
73.2k
        }
346
        
347
60.6k
        pub fn read_to_string_shim(path: &Path) -> Result<String> {
348
60.6k
            let path = match path.canonicalize() {
349
60.6k
                Ok(path) => path,
350
0
                Err(e) => bail!("Read error while trying to canonicalize in read_to_string_shim {}: {}", path.display(), e),
351
            };
352
60.6k
            debug!("Reading file '{}'", 
&path.display()0
);
353
60.6k
            match std::fs::read_to_string(&path) {
354
60.6k
                Ok(str) => return Ok(str),
355
0
                Err(e) => bail!("Read error while trying to read {}: {}", &path.display(), e),
356
            }
357
60.6k
        }
358
359
12.6k
        pub fn zip_extract_shim(dir: &Path, zip_file_name: &str) -> Result<bool> {
360
12.6k
            let zip_file = dir.join(zip_file_name);
361
12.6k
            return match std::fs::read(zip_file) {
362
12.6k
                Err(e) => {
363
                    // no zip file? -- maybe started out with all the files unzipped? See if there is a .yaml file
364
12.6k
                    let yaml_files = find_files_in_dir_that_ends_with_shim(dir, ".yaml");
365
12.6k
                    if yaml_files.is_empty() {
366
1.44k
                        bail!("{}", e)
367
                    } else {
368
11.2k
                        Ok(false)
369
                    }
370
                },
371
0
                Ok(contents) => {
372
0
                    let archive = std::io::Cursor::new(contents);
373
0
                    let mut zip_archive = zip::ZipArchive::new(archive).unwrap();
374
0
                    zip_archive.extract(dir).expect("Zip extraction failed");
375
0
                    Ok(true)
376
                },
377
            };
378
12.6k
        }
379
    }
380
}