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
200pub(crate) fn single_non_rockspec_entry(dir: &Path) -> Result<Option<PathBuf>, 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 path = f.path();
206 path.extension()
207 .is_none_or(|ext| ext != "rockspec")
208 .then_some(path)
209 })
210 .collect_vec();
211 if entries.len() == 1 {
212 Ok(entries.into_iter().next())
213 } else {
214 Ok(None)
215 }
216}
217
218fn get_single_archive_entry(dir: &Path) -> Result<Option<(PathBuf, Option<&str>)>, io::Error> {
219 if let Some(entry) = single_non_rockspec_entry(dir)? {
220 if entry.is_file() {
221 if let mt @ Some(mime_type) =
222 infer::get_from_path(&entry)?.map(|file_type| file_type.mime_type())
223 {
224 if matches!(
225 mime_type,
226 "application/zip" | "application/x-tar" | "application/gzip"
227 ) {
228 return Ok(Some((entry, mt)));
229 }
230 }
231 }
232 }
233 Ok(None)
234}
235
236#[cfg(test)]
237mod tests {
238 use assert_fs::TempDir;
239 use std::{fs, fs::File, io::Write};
240
241 use super::*;
242
243 fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
244 let mut buf = Vec::new();
245 {
246 let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
247 let options = zip::write::SimpleFileOptions::default();
248 for (name, content) in entries {
249 writer.start_file(*name, options).unwrap();
250 writer.write_all(content).unwrap();
251 }
252 writer.finish().unwrap();
253 }
254 buf
255 }
256
257 #[tokio::test]
258 pub async fn test_unpack_src_rock() {
259 let test_rock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
260 .join("resources")
261 .join("test")
262 .join("luatest-0.2-1.src.rock");
263 let file = File::open(&test_rock_path).unwrap();
264 let dest = TempDir::new().unwrap();
265 unpack_src_rock(file, dest.to_path_buf()).await.unwrap();
266 }
267
268 #[tokio::test]
269 async fn test_unpack_src_rock_extracts_nested_archive() {
270 let source_archive = make_zip(&[("foo-1.0/lua/foo.lua", b"return {}")]);
271 let src_rock = make_zip(&[
272 (
273 "foo-1.0-1.rockspec",
274 br#"package = 'foo'
275version = '1.0-1'
276source = { url = 'https://example.com/foo.zip', dir = 'foo-1.0' }
277"#,
278 ),
279 ("foo-1.0.zip", &source_archive),
280 ]);
281 let dest = TempDir::new().unwrap();
282
283 unpack_src_rock(Cursor::new(src_rock), dest.to_path_buf())
284 .await
285 .unwrap();
286
287 assert!(dest.path().join("foo-1.0/lua/foo.lua").is_file());
288 assert!(!dest.path().join("foo-1.0.zip").exists());
289 }
290
291 #[test]
292 fn test_get_single_archive_entry_extracted_dir_without_dots() {
293 let dest = TempDir::new().unwrap();
294
295 fs::write(dest.path().join("foo-1.0-1.rockspec"), b"").unwrap();
296 fs::write(
297 dest.path().join("foo-1.0.zip"),
298 make_zip(&[("dummy.lua", b"println('hello')")]),
299 )
300 .unwrap();
301 fs::create_dir(dest.path().join("test_dir")).unwrap();
302
303 assert!(get_single_archive_entry(dest.path()).unwrap().is_none());
304 }
305
306 #[test]
307 fn test_get_single_archive_entry_nested_archive() {
308 let dest = TempDir::new().unwrap();
309
310 fs::write(dest.path().join("foo-1.0-1.rockspec"), b"").unwrap();
311 fs::write(
312 dest.path().join("foo-1.0.zip"),
313 make_zip(&[("dummy.lua", b"println('hello')")]),
314 )
315 .unwrap();
316
317 let result = get_single_archive_entry(dest.path()).unwrap();
318 assert!(result.is_some());
319 assert_eq!(result.unwrap().0, dest.path().join("foo-1.0.zip"));
320 }
321}