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
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
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}