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
33pub async fn unpack_src_rock<R: Read + Seek + Send + 'static>(
34 rock_src: R,
35 destination: PathBuf,
36) -> Result<PathBuf, UnpackError> {
37 unpack(
38 Some("application/zip"),
39 rock_src,
40 true,
41 "src.rock".to_string(),
42 &destination,
43 )
44 .await?;
45 Ok(destination)
46}
47
48#[tracing::instrument(
49 name = "Unpacking file",
50 skip_all,
51 fields(file_name = %file_name)
52)]
53#[async_recursion]
54pub(crate) async fn unpack<R>(
55 mime_type: Option<&str>,
56 reader: R,
57 extract_nested_archive: bool,
58 file_name: String,
59 dest_dir: &Path,
60) -> Result<(), UnpackError>
61where
62 R: Read + Seek + Send + 'static,
64{
65 let mime_type = mime_type.map(str::to_string);
66 let dest_dir = dest_dir.to_path_buf();
67 let dest_dir_inner = dest_dir.clone();
68
69 tokio::task::spawn_blocking(move || {
70 extract_archive(
73 mime_type.as_deref(),
74 reader,
75 extract_nested_archive,
76 &dest_dir_inner,
77 )
78 })
79 .await
80 .map_err(|err| UnpackError::Io(io::Error::other(err)))??;
81
82 if extract_nested_archive {
83 if let Some((nested_archive_path, mime_type)) = get_single_archive_entry(&dest_dir)? {
86 tracing::debug!("nested archive path: {}", nested_archive_path.display());
87 let file_name = nested_archive_path
88 .file_name()
89 .map(|os_str| os_str.to_string_lossy())
90 .unwrap_or(nested_archive_path.to_string_lossy())
91 .to_string();
92 let buffer = fs::tokio::read(&nested_archive_path).await?;
93 unpack(
94 mime_type,
95 Cursor::new(buffer),
96 extract_nested_archive, file_name,
98 &dest_dir,
99 )
100 .await?;
101 fs::tokio::remove_file(&nested_archive_path).await?;
102 }
103 }
104 Ok(())
105}
106
107fn extract_archive<R: Read + Seek + Send>(
108 mime_type: Option<&str>,
109 reader: R,
110 extract_nested_archive: bool,
111 dest_dir: &Path,
112) -> Result<(), UnpackError> {
113 match mime_type {
114 Some("application/zip") => {
115 tracing::debug!("extracting zip archive");
116 let mut archive = zip::ZipArchive::new(reader)?;
117 archive.extract(dest_dir)?;
118 }
119 Some("application/x-tar") => {
120 tracing::debug!("extracting tar archive");
121 let mut archive = tar::Archive::new(reader);
122 archive.unpack(dest_dir)?;
123 }
124 Some("application/gzip") => {
125 tracing::debug!("extracting gzip archive");
126 let mut bufreader = BufReader::new(reader);
127
128 let extract_subdirectory =
129 extract_nested_archive && is_single_tar_directory(&mut bufreader)?;
130
131 bufreader.rewind()?;
132 let tar = GzDecoder::new(bufreader);
133 let mut archive = tar::Archive::new(tar);
134
135 if extract_subdirectory {
136 archive.entries()?.try_for_each(|entry| {
137 let mut entry = entry?;
138
139 let path: PathBuf = entry.path()?.components().skip(1).collect();
140 if path.components().count() > 0 {
141 let dest = dest_dir.join(path);
142 if let Some(dest_parent_dir) = dest.parent() {
143 fs::sync::create_dir_all(dest_parent_dir).map_err(io::Error::other)?;
144 }
145 entry.unpack(dest)?;
146 }
147
148 Ok::<_, io::Error>(())
149 })?;
150 } else {
151 archive.entries()?.try_for_each(|entry| {
152 entry?.unpack_in(dest_dir)?;
153 Ok::<_, io::Error>(())
154 })?;
155 }
156 }
157 Some("text/html") => {
158 return Err(UnpackError::SourceMovedOrDeleted);
159 }
160 Some(other) => {
161 return Err(UnpackError::UnsupportedFileType(other.to_string()));
162 }
163 None => {
164 return Err(UnpackError::UnknownMimeType);
165 }
166 }
167
168 Ok(())
169}
170
171fn is_single_tar_directory<R: Read + Seek + Send>(reader: R) -> io::Result<bool> {
172 let tar = GzDecoder::new(reader);
173 let mut archive = tar::Archive::new(tar);
174
175 let entries: Vec<_> = archive
176 .entries()?
177 .filter_map(|entry| {
178 if entry.as_ref().ok()?.path().ok()?.file_name()? != "pax_global_header" {
179 Some(entry)
180 } else {
181 None
182 }
183 })
184 .try_collect()?;
185
186 if entries.is_empty() {
187 Ok(false)
188 } else {
189 let directory: PathBuf = entries[0].path()?.components().take(1).collect();
190
191 Ok(entries.into_iter().all(|entry| {
192 entry.path().is_ok_and(|path| {
193 path.to_slash_lossy()
194 .starts_with(&directory.to_slash_lossy().to_string())
195 })
196 }))
197 }
198}
199
200fn get_single_archive_entry(dir: &Path) -> Result<Option<(PathBuf, Option<&str>)>, io::Error> {
201 let entries = fs::sync::read_dir(dir)
202 .map_err(io::Error::other)?
203 .filter_map(Result::ok)
204 .filter_map(|f| {
205 let f = f.path();
206 f.extension()
207 .is_none_or(|ext| ext != "rockspec")
208 .then_some(f)
209 })
210 .collect_vec();
211 if entries.len() != 1 {
212 return Ok(None);
213 }
214 match entries.first() {
215 Some(entry) if entry.is_file() => {
216 if let mt @ Some(mime_type) =
217 infer::get_from_path(entry)?.map(|file_type| file_type.mime_type())
218 {
219 if matches!(
220 mime_type,
221 "application/zip" | "application/x-tar" | "application/gzip"
222 ) {
223 return Ok(Some((entry.clone(), mt)));
224 }
225 }
226 Ok(None)
227 }
228 _ => Ok(None),
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use assert_fs::TempDir;
235 use std::{fs, fs::File, io::Write};
236
237 use super::*;
238
239 fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
240 let mut buf = Vec::new();
241 {
242 let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
243 let options = zip::write::SimpleFileOptions::default();
244 for (name, content) in entries {
245 writer.start_file(*name, options).unwrap();
246 writer.write_all(content).unwrap();
247 }
248 writer.finish().unwrap();
249 }
250 buf
251 }
252
253 #[tokio::test]
254 pub async fn test_unpack_src_rock() {
255 let test_rock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
256 .join("resources")
257 .join("test")
258 .join("luatest-0.2-1.src.rock");
259 let file = File::open(&test_rock_path).unwrap();
260 let dest = TempDir::new().unwrap();
261 unpack_src_rock(file, dest.to_path_buf()).await.unwrap();
262 }
263
264 #[tokio::test]
265 async fn test_unpack_src_rock_extracts_nested_archive() {
266 let source_archive = make_zip(&[("foo-1.0/lua/foo.lua", b"return {}")]);
267 let src_rock = make_zip(&[
268 (
269 "foo-1.0-1.rockspec",
270 br#"package = 'foo'
271version = '1.0-1'
272source = { url = 'https://example.com/foo.zip', dir = 'foo-1.0' }
273"#,
274 ),
275 ("foo-1.0.zip", &source_archive),
276 ]);
277 let dest = TempDir::new().unwrap();
278
279 unpack_src_rock(Cursor::new(src_rock), dest.to_path_buf())
280 .await
281 .unwrap();
282
283 assert!(dest.path().join("foo-1.0/lua/foo.lua").is_file());
284 assert!(!dest.path().join("foo-1.0.zip").exists());
285 }
286
287 #[test]
288 fn test_get_single_archive_entry_extracted_dir_without_dots() {
289 let dest = TempDir::new().unwrap();
290
291 fs::write(dest.path().join("foo-1.0-1.rockspec"), b"").unwrap();
292 fs::write(
293 dest.path().join("foo-1.0.zip"),
294 make_zip(&[("dummy.lua", b"println('hello')")]),
295 )
296 .unwrap();
297 fs::create_dir(dest.path().join("test_dir")).unwrap();
298
299 assert!(get_single_archive_entry(dest.path()).unwrap().is_none());
300 }
301
302 #[test]
303 fn test_get_single_archive_entry_nested_archive() {
304 let dest = TempDir::new().unwrap();
305
306 fs::write(dest.path().join("foo-1.0-1.rockspec"), b"").unwrap();
307 fs::write(
308 dest.path().join("foo-1.0.zip"),
309 make_zip(&[("dummy.lua", b"println('hello')")]),
310 )
311 .unwrap();
312
313 let result = get_single_archive_entry(dest.path()).unwrap();
314 assert!(result.is_some());
315 assert_eq!(result.unwrap().0, dest.path().join("foo-1.0.zip"));
316 }
317}