lux_lib/operations/
unpack.rs1use crate::fs;
2use async_recursion::async_recursion;
3use flate2::read::GzDecoder;
4use itertools::Itertools;
5use miette::Diagnostic;
6use path_slash::PathExt;
7use std::io;
8use std::io::BufReader;
9use std::io::Cursor;
10use std::io::Read;
11use std::io::Seek;
12use std::path::Path;
13use std::path::PathBuf;
14use thiserror::Error;
15
16#[derive(Error, Debug, Diagnostic)]
17pub enum UnpackError {
18 #[error("failed to unpack source")]
19 Io(#[from] io::Error),
20 #[error(transparent)]
21 #[diagnostic(transparent)]
22 Fs(#[from] fs::FsError),
23 #[error("failed to unpack zip source")]
24 Zip(#[from] zip::result::ZipError),
25 #[error("source returned HTML - it may have been moved or deleted")]
26 SourceMovedOrDeleted,
27 #[error("rockspec source has unsupported file type '{0}'")]
28 UnsupportedFileType(String),
29 #[error("could not determine mimetype of rockspec source")]
30 UnknownMimeType,
31}
32
33#[tracing::instrument(name = "Unpacking src.rock", skip_all)]
34pub async fn unpack_src_rock<R: Read + Seek + Send + 'static>(
35 rock_src: R,
36 destination: PathBuf,
37) -> Result<PathBuf, UnpackError> {
38 let dest = tokio::task::spawn_blocking(move || {
39 let mut zip = zip::ZipArchive::new(rock_src)?;
40 zip.extract(&destination)?;
41 Ok::<PathBuf, UnpackError>(destination)
42 })
43 .await
44 .map_err(|err| UnpackError::Io(io::Error::other(err)))??;
45 Ok(dest)
46}
47
48#[tracing::instrument(name = "Unpacking file", skip_all)]
49#[async_recursion]
50pub(crate) async fn unpack<R>(
51 mime_type: Option<&str>,
52 reader: R,
53 extract_nested_archive: bool,
54 _file_name: String,
55 dest_dir: &Path,
56) -> Result<(), UnpackError>
57where
58 R: Read + Seek + Send + 'static,
60{
61 let mime_type = mime_type.map(str::to_string);
62 let dest_dir = dest_dir.to_path_buf();
63 let dest_dir_inner = dest_dir.clone();
64
65 tokio::task::spawn_blocking(move || {
66 extract_archive(
69 mime_type.as_deref(),
70 reader,
71 extract_nested_archive,
72 &dest_dir_inner,
73 )
74 })
75 .await
76 .map_err(|err| UnpackError::Io(io::Error::other(err)))??;
77
78 if extract_nested_archive {
79 if let Some((nested_archive_path, mime_type)) = get_single_archive_entry(&dest_dir)? {
82 tracing::debug!("nested archive path: {}", nested_archive_path.display());
83 let file_name = nested_archive_path
84 .file_name()
85 .map(|os_str| os_str.to_string_lossy())
86 .unwrap_or(nested_archive_path.to_string_lossy())
87 .to_string();
88 let buffer = fs::tokio::read(&nested_archive_path).await?;
89 unpack(
90 mime_type,
91 Cursor::new(buffer),
92 extract_nested_archive, file_name,
94 &dest_dir,
95 )
96 .await?;
97 fs::tokio::remove_file(&nested_archive_path).await?;
98 }
99 }
100 Ok(())
101}
102
103fn extract_archive<R: Read + Seek + Send>(
104 mime_type: Option<&str>,
105 reader: R,
106 extract_nested_archive: bool,
107 dest_dir: &Path,
108) -> Result<(), UnpackError> {
109 match mime_type {
110 Some("application/zip") => {
111 tracing::debug!("extracting zip archive");
112 let mut archive = zip::ZipArchive::new(reader)?;
113 archive.extract(dest_dir)?;
114 }
115 Some("application/x-tar") => {
116 tracing::debug!("extracting tar archive");
117 let mut archive = tar::Archive::new(reader);
118 archive.unpack(dest_dir)?;
119 }
120 Some("application/gzip") => {
121 tracing::debug!("extracting gzip archive");
122 let mut bufreader = BufReader::new(reader);
123
124 let extract_subdirectory =
125 extract_nested_archive && is_single_tar_directory(&mut bufreader)?;
126
127 bufreader.rewind()?;
128 let tar = GzDecoder::new(bufreader);
129 let mut archive = tar::Archive::new(tar);
130
131 if extract_subdirectory {
132 archive.entries()?.try_for_each(|entry| {
133 let mut entry = entry?;
134
135 let path: PathBuf = entry.path()?.components().skip(1).collect();
136 if path.components().count() > 0 {
137 let dest = dest_dir.join(path);
138 if let Some(dest_parent_dir) = dest.parent() {
139 fs::sync::create_dir_all(dest_parent_dir).map_err(io::Error::other)?;
140 }
141 entry.unpack(dest)?;
142 }
143
144 Ok::<_, io::Error>(())
145 })?;
146 } else {
147 archive.entries()?.try_for_each(|entry| {
148 entry?.unpack_in(dest_dir)?;
149 Ok::<_, io::Error>(())
150 })?;
151 }
152 }
153 Some("text/html") => {
154 return Err(UnpackError::SourceMovedOrDeleted);
155 }
156 Some(other) => {
157 return Err(UnpackError::UnsupportedFileType(other.to_string()));
158 }
159 None => {
160 return Err(UnpackError::UnknownMimeType);
161 }
162 }
163
164 Ok(())
165}
166
167fn is_single_tar_directory<R: Read + Seek + Send>(reader: R) -> io::Result<bool> {
168 let tar = GzDecoder::new(reader);
169 let mut archive = tar::Archive::new(tar);
170
171 let entries: Vec<_> = archive
172 .entries()?
173 .filter_map(|entry| {
174 if entry.as_ref().ok()?.path().ok()?.file_name()? != "pax_global_header" {
175 Some(entry)
176 } else {
177 None
178 }
179 })
180 .try_collect()?;
181
182 if entries.is_empty() {
183 Ok(false)
184 } else {
185 let directory: PathBuf = entries[0].path()?.components().take(1).collect();
186
187 Ok(entries.into_iter().all(|entry| {
188 entry.path().is_ok_and(|path| {
189 path.to_slash_lossy()
190 .starts_with(&directory.to_slash_lossy().to_string())
191 })
192 }))
193 }
194}
195
196fn get_single_archive_entry(dir: &Path) -> Result<Option<(PathBuf, Option<&str>)>, io::Error> {
197 let entries = fs::sync::read_dir(dir)
198 .map_err(io::Error::other)?
199 .filter_map(Result::ok)
200 .filter_map(|f| {
201 let f = f.path();
202 f.extension()
203 .is_none_or(|ext| ext != "rockspec")
204 .then_some(f)
205 })
206 .collect_vec();
207 if entries.len() != 1 {
208 return Ok(None);
209 }
210 match entries.first() {
211 Some(entry) if entry.is_file() => {
212 if let mt @ Some(mime_type) =
213 infer::get_from_path(entry)?.map(|file_type| file_type.mime_type())
214 {
215 if matches!(
216 mime_type,
217 "application/zip" | "application/x-tar" | "application/gzip"
218 ) {
219 return Ok(Some((entry.clone(), mt)));
220 }
221 }
222 Ok(None)
223 }
224 _ => Ok(None),
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use assert_fs::TempDir;
231 use std::{fs, fs::File, io::Write};
232
233 use super::*;
234
235 fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
236 let mut buf = Vec::new();
237 {
238 let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
239 let options = zip::write::SimpleFileOptions::default();
240 for (name, content) in entries {
241 writer.start_file(*name, options).unwrap();
242 writer.write_all(content).unwrap();
243 }
244 writer.finish().unwrap();
245 }
246 buf
247 }
248
249 #[tokio::test]
250 pub async fn test_unpack_src_rock() {
251 let test_rock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
252 .join("resources")
253 .join("test")
254 .join("luatest-0.2-1.src.rock");
255 let file = File::open(&test_rock_path).unwrap();
256 let dest = TempDir::new().unwrap();
257 unpack_src_rock(file, dest.to_path_buf()).await.unwrap();
258 }
259
260 #[test]
261 fn test_get_single_archive_entry_extracted_dir_without_dots() {
262 let dest = TempDir::new().unwrap();
263
264 fs::write(dest.path().join("foo-1.0-1.rockspec"), b"").unwrap();
265 fs::write(
266 dest.path().join("foo-1.0.zip"),
267 make_zip(&[("dummy.lua", b"println('hello')")]),
268 )
269 .unwrap();
270 fs::create_dir(dest.path().join("test_dir")).unwrap();
271
272 assert!(get_single_archive_entry(dest.path()).unwrap().is_none());
273 }
274
275 #[test]
276 fn test_get_single_archive_entry_nested_archive() {
277 let dest = TempDir::new().unwrap();
278
279 fs::write(dest.path().join("foo-1.0-1.rockspec"), b"").unwrap();
280 fs::write(
281 dest.path().join("foo-1.0.zip"),
282 make_zip(&[("dummy.lua", b"println('hello')")]),
283 )
284 .unwrap();
285
286 let result = get_single_archive_entry(dest.path()).unwrap();
287 assert!(result.is_some());
288 assert_eq!(result.unwrap().0, dest.path().join("foo-1.0.zip"));
289 }
290}