Skip to main content

loadsmith_thunderstore/r2z/
import.rs

1use std::{
2    fs,
3    io::{self, Cursor, Read, Seek},
4    path::{Path, PathBuf},
5    sync::LazyLock,
6};
7
8use camino::{Utf8Path, Utf8PathBuf};
9use globset::{Glob, GlobSet, GlobSetBuilder};
10use loadsmith_core::{Checksum, ChecksumAlgorithm};
11use serde::de::DeserializeOwned;
12use tracing::trace;
13use zip::ZipArchive;
14
15use crate::{Error, Result, r2z::ProfileManifest};
16
17/// Reads an r2z (r2modman zip) profile export.
18///
19/// Provides access to the manifest and config files stored in the archive.
20///
21/// # Examples
22///
23/// ```
24/// use std::io::Cursor;
25/// use loadsmith_thunderstore::r2z::{ImportFile, ExportFile, ProfileManifest, Mod, Version};
26/// use thunderstore::PackageIdent;
27///
28/// let manifest = ProfileManifest::new(
29///     "Test",
30///     vec![Mod::new(PackageIdent::new("A", "B"), Version::new(1, 0, 0), true)],
31///     (),
32/// );
33/// let data = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap().finish().unwrap();
34///
35/// let mut import = ImportFile::open(data).unwrap();
36/// let read = import.read_manifest::<()>().unwrap();
37/// assert_eq!(read.profile_name, "Test");
38/// ```
39pub struct ImportFile<R> {
40    zip: ZipArchive<R>,
41}
42
43impl<R: Read + Seek> ImportFile<R> {
44    /// Opens an r2z archive for reading.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use std::io::Cursor;
50    /// use loadsmith_thunderstore::r2z::ImportFile;
51    ///
52    /// let import = ImportFile::open(Cursor::new(Vec::new()));
53    /// ```
54    pub fn open(reader: R) -> Result<Self> {
55        Ok(Self {
56            zip: ZipArchive::new(reader)?,
57        })
58    }
59
60    /// Reads and deserialises the `export.r2x` manifest from the archive.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use std::io::Cursor;
66    /// use loadsmith_thunderstore::r2z::{ImportFile, ExportFile, ProfileManifest};
67    ///
68    /// let manifest = ProfileManifest::new("Test", vec![], ());
69    /// let data = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap().finish().unwrap();
70    ///
71    /// let mut import = ImportFile::open(data).unwrap();
72    /// let read: ProfileManifest<()> = import.read_manifest().unwrap();
73    /// assert_eq!(read.profile_name, "Test");
74    /// ```
75    pub fn read_manifest<T: DeserializeOwned>(&mut self) -> Result<ProfileManifest<T>> {
76        let file = self.zip.by_name("export.r2x").map_err(|err| match err {
77            zip::result::ZipError::FileNotFound => Error::ProfileManifestNotFound,
78            err => Error::Zip(err),
79        })?;
80
81        let manifest: ProfileManifest<T> = serde_yaml_ng::from_reader(file)?;
82
83        Ok(manifest)
84    }
85
86    /// Iterates over every file in the archive (except `export.r2x`)
87    /// and invokes `callback` with the file path and a reader.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use std::io::Cursor;
93    /// use loadsmith_thunderstore::r2z::{ImportFile, ExportFile, ProfileManifest, Mod, Version};
94    /// use thunderstore::PackageIdent;
95    ///
96    /// let manifest = ProfileManifest::new(
97    ///     "Test",
98    ///     vec![Mod::new(PackageIdent::new("A", "B"), Version::new(1, 0, 0), true)],
99    ///     (),
100    /// );
101    /// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
102    /// export.write_file("config.cfg", &b"enabled=true"[..]).unwrap();
103    /// let data = export.finish().unwrap();
104    ///
105    /// let mut import = ImportFile::open(data).unwrap();
106    /// import.read_files(|path, _reader| {
107    ///     assert_eq!(path, std::path::Path::new("config.cfg"));
108    ///     Ok(())
109    /// }).unwrap();
110    /// ```
111    pub fn read_files<F>(&mut self, mut callback: F) -> Result<()>
112    where
113        F: FnMut(PathBuf, &mut dyn Read) -> Result<()>,
114    {
115        for i in 0..self.zip.len() {
116            let mut file = self.zip.by_index(i)?;
117            if file.name() == "export.r2x" {
118                continue;
119            }
120
121            let path = file.mangled_name();
122            callback(path, &mut file)?;
123        }
124
125        Ok(())
126    }
127
128    /// Extracts config files from the archive into a target directory.
129    ///
130    /// When `filter` is `true`, executable and script file types (`.dll`,
131    /// `.exe`, `.bat`, etc.) as well as `export.r2x` and `mods.yml` are
132    /// skipped. Files under a `config/` prefix are mapped to `BepInEx/config/`.
133    /// Files that already exist with identical content are skipped.
134    ///
135    /// # Examples
136    ///
137    /// ```no_run
138    /// use std::io::Cursor;
139    /// use loadsmith_thunderstore::r2z::{ImportFile, ExportFile, ProfileManifest};
140    ///
141    /// let manifest = ProfileManifest::new("Test", vec![], ());
142    /// let data = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap().finish().unwrap();
143    ///
144    /// let mut import = ImportFile::open(data).unwrap();
145    /// import.import_config_files("./output", true).unwrap();
146    /// ```
147    pub fn import_config_files(&mut self, target: impl AsRef<Path>, filter: bool) -> Result<()> {
148        let target = target.as_ref();
149        self.read_files(|relative_path, reader| {
150            let mut relative_path = Utf8PathBuf::from(relative_path.to_string_lossy().into_owned());
151
152            if relative_path.starts_with("config") {
153                relative_path = Utf8PathBuf::from("BepInEx").join(relative_path);
154            }
155
156            if filter && is_excluded(&relative_path) {
157                trace!(path = %relative_path, "skipping excluded file");
158                return Ok(());
159            }
160
161            let target_path = target.join(&relative_path);
162
163            let mut buf = Vec::new();
164            reader.read_to_end(&mut buf)?;
165
166            if target_path.exists() {
167                if Checksum::compute_from_path(&target_path, ChecksumAlgorithm::Blake3)?
168                    == Checksum::compute(Cursor::new(&buf), ChecksumAlgorithm::Blake3)?
169                {
170                    trace!(path = %relative_path, "skipping identical file");
171                    return Ok(());
172                }
173            }
174
175            trace!(path = %relative_path, "importing file");
176
177            loadsmith_util::create_parent_dirs(&target_path)?;
178
179            let mut file = fs::File::create(target_path)?;
180            io::copy(&mut Cursor::new(buf), &mut file)?;
181
182            Ok(())
183        })
184    }
185}
186
187fn is_excluded(relative_path: impl AsRef<Utf8Path>) -> bool {
188    static EXCLUDE_SET: LazyLock<GlobSet> = LazyLock::new(|| {
189        GlobSetBuilder::new()
190            .add(Glob::new("export.r2x").unwrap())
191            .add(Glob::new("mods.yml").unwrap())
192            .add(Glob::new("*.{dll,exe,scr,com,pif,bat,cmd,ps1,vbs,vbe,js,jse,wsf,wsh,hta,msi,msix,sys,drv,cpl,ocx,lnk,reg,inf}").unwrap())
193            .build()
194            .unwrap()
195    });
196
197    EXCLUDE_SET.is_match(relative_path.as_ref())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_is_excluded() {
206        assert!(is_excluded("export.r2x"));
207        assert!(is_excluded("mods.yml"));
208        assert!(!is_excluded("nested/mods.yml"));
209        assert!(is_excluded("some/path/to/file.dll"));
210        assert!(!is_excluded("some/path/to/file.txt"));
211        assert!(!is_excluded("some/path/to/file.json"));
212    }
213}