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}