Skip to main content

modde_core/installer/
fs.rs

1//! Filesystem helpers shared by the install pipeline.
2//!
3//! `extract_archive` and `find_fomod_config` used to live in
4//! `modde-cli/src/commands/install.rs`. They are hoisted here so both the
5//! CLI and the UI install paths share a single implementation, and so the
6//! installer crate can probe extracted archives directly without reaching
7//! back into CLI code.
8
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use tracing::warn;
14
15use super::types::{InstallerError, InstallerResult};
16
17/// Extract a zip archive into `dest`. Refuses entries whose paths would
18/// escape `dest` (e.g. `../../etc/passwd`) — zip's `enclosed_name` guard.
19pub fn extract_archive(archive_path: &Path, dest: &Path) -> InstallerResult<()> {
20    let file = fs::File::open(archive_path)
21        .map_err(|e| InstallerError::Extract(format!("open {}: {e}", archive_path.display())))?;
22    let mut archive = zip::ZipArchive::new(file)
23        .map_err(|e| InstallerError::Extract(format!("read {}: {e}", archive_path.display())))?;
24
25    for i in 0..archive.len() {
26        let mut entry = archive
27            .by_index(i)
28            .map_err(|e| InstallerError::Extract(format!("entry {i}: {e}")))?;
29        let Some(name) = entry.enclosed_name() else {
30            warn!("skipping archive entry with unsafe path");
31            continue;
32        };
33        let out_path = dest.join(name);
34
35        if entry.is_dir() {
36            fs::create_dir_all(&out_path)?;
37        } else {
38            if let Some(parent) = out_path.parent() {
39                fs::create_dir_all(parent)?;
40            }
41            let mut out_file = fs::File::create(&out_path)?;
42            io::copy(&mut entry, &mut out_file)?;
43        }
44    }
45
46    Ok(())
47}
48
49/// Locate a FOMOD `ModuleConfig.xml` under `mod_dir`, trying the canonical
50/// path first and then falling back to case-insensitive matching for
51/// archives built on case-sensitive filesystems.
52///
53/// Returns the absolute path, or `None` if this mod is not FOMOD-packaged.
54#[must_use]
55pub fn find_fomod_config(mod_dir: &Path) -> Option<PathBuf> {
56    let canonical = mod_dir.join("fomod").join("ModuleConfig.xml");
57    if canonical.exists() {
58        return Some(canonical);
59    }
60
61    let entries = fs::read_dir(mod_dir).ok()?;
62    for entry in entries.flatten() {
63        if !entry.path().is_dir() {
64            continue;
65        }
66        if !entry.file_name().eq_ignore_ascii_case("fomod") {
67            continue;
68        }
69        let inner_entries = fs::read_dir(entry.path()).ok()?;
70        for inner in inner_entries.flatten() {
71            if inner.file_name().eq_ignore_ascii_case("moduleconfig.xml") {
72                return Some(inner.path());
73            }
74        }
75    }
76    None
77}
78
79/// Walk `dir` recursively, returning (absolute path, relative path) pairs
80/// for every regular file. Used by `execute` when staging files.
81pub(crate) fn walk_files(dir: &Path) -> InstallerResult<Vec<(PathBuf, PathBuf)>> {
82    let mut out = Vec::new();
83    walk_files_into(dir, dir, &mut out)?;
84    Ok(out)
85}
86
87fn walk_files_into(
88    base: &Path,
89    dir: &Path,
90    out: &mut Vec<(PathBuf, PathBuf)>,
91) -> InstallerResult<()> {
92    if !dir.exists() {
93        return Ok(());
94    }
95    for entry in fs::read_dir(dir)? {
96        let entry = entry?;
97        let path = entry.path();
98        if path.is_dir() {
99            walk_files_into(base, &path, out)?;
100        } else if path.is_file()
101            && let Ok(rel) = path.strip_prefix(base)
102        {
103            out.push((path.clone(), rel.to_path_buf()));
104        }
105    }
106    Ok(())
107}
108
109/// Hash a file with xxh64, returning the hex digest. Used to populate
110/// `InstallPlan::source_archive_hash` on download.
111pub fn xxh64_file_hex(path: &Path) -> InstallerResult<String> {
112    use std::io::Read;
113    use xxhash_rust::xxh64::Xxh64;
114
115    let mut file = fs::File::open(path)?;
116    let mut hasher = Xxh64::new(0);
117    let mut buf = [0u8; 64 * 1024];
118    loop {
119        let n = file.read(&mut buf)?;
120        if n == 0 {
121            break;
122        }
123        hasher.update(&buf[..n]);
124    }
125    Ok(format!("{:016x}", hasher.digest()))
126}