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::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    // NOTE: tokio::spawn_blocking requires Send + 'static
59    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        // NOTE: unpacking does not support async IO
67        // Maybe in the future: https://github.com/zip-rs/zip2/issues/108
68        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 the source is an archive, luarocks will pack the source archive and the rockspec.
80        // So we need to unpack the source archive.
81        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, // It might be a nested archive inside a .src.rock
93                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}