Skip to main content

lux_lib/operations/
unpack.rs

1use 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::Error),
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    // NOTE: tokio::spawn_blocking requires Send + 'static
63    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        // NOTE: unpacking does not support async IO
71        // Maybe in the future: https://github.com/zip-rs/zip2/issues/108
72        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 the source is an archive, luarocks will pack the source archive and the rockspec.
84        // So we need to unpack the source archive.
85        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, // It might be a nested archive inside a .src.rock
97                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}