lux_lib/operations/
unpack.rs

1use async_recursion::async_recursion;
2use flate2::read::GzDecoder;
3use itertools::Itertools;
4use std::fs;
5use std::fs::File;
6use std::io;
7use std::io::BufReader;
8use std::io::Read;
9use std::io::Seek;
10use std::path::Path;
11use std::path::PathBuf;
12use thiserror::Error;
13
14use crate::progress::Progress;
15use crate::progress::ProgressBar;
16
17#[derive(Error, Debug)]
18pub enum UnpackError {
19    #[error("failed to unpack source: {0}")]
20    Io(#[from] io::Error),
21    #[error("failed to unpack zip source: {0}")]
22    Zip(#[from] zip::result::ZipError),
23    #[error("source returned HTML - it may have been moved or deleted")]
24    SourceMovedOrDeleted,
25    #[error("rockspec source has unsupported file type {0}")]
26    UnsupportedFileType(String),
27    #[error("could not determine mimetype of rockspec source")]
28    UnknownMimeType,
29}
30
31pub async fn unpack_src_rock<R: Read + Seek + Send>(
32    rock_src: R,
33    destination: PathBuf,
34    progress: &Progress<ProgressBar>,
35) -> Result<PathBuf, UnpackError> {
36    progress.map(|p| {
37        p.set_message(format!(
38            "📦 Unpacking src.rock into {}",
39            destination.display()
40        ))
41    });
42
43    unpack_src_rock_impl(rock_src, destination).await
44}
45
46async fn unpack_src_rock_impl<R: Read + Seek + Send>(
47    rock_src: R,
48    destination: PathBuf,
49) -> Result<PathBuf, UnpackError> {
50    let mut zip = zip::ZipArchive::new(rock_src)?;
51    zip.extract(&destination)?;
52    Ok(destination)
53}
54
55#[async_recursion]
56pub(crate) async fn unpack<R>(
57    mime_type: Option<&str>,
58    reader: R,
59    extract_nested_archive: bool,
60    file_name: String,
61    dest_dir: &Path,
62    progress: &Progress<ProgressBar>,
63) -> Result<(), UnpackError>
64where
65    R: Read + Seek + Send,
66{
67    progress.map(|p| p.set_message(format!("📦 Unpacking {file_name}")));
68
69    match mime_type {
70        Some("application/zip") => {
71            let mut archive = zip::ZipArchive::new(reader)?;
72            archive.extract(dest_dir)?;
73        }
74        Some("application/x-tar") => {
75            let mut archive = tar::Archive::new(reader);
76            archive.unpack(dest_dir)?;
77        }
78        Some("application/gzip") => {
79            let mut bufreader = BufReader::new(reader);
80
81            let extract_subdirectory =
82                extract_nested_archive && is_single_tar_directory(&mut bufreader)?;
83
84            bufreader.rewind()?;
85            let tar = GzDecoder::new(bufreader);
86            let mut archive = tar::Archive::new(tar);
87
88            if extract_subdirectory {
89                archive.entries()?.try_for_each(|entry| {
90                    let mut entry = entry?;
91
92                    let path: PathBuf = entry.path()?.components().skip(1).collect();
93                    if path.components().count() > 0 {
94                        let dest = dest_dir.join(path);
95                        std::fs::create_dir_all(dest.parent().unwrap())?;
96                        entry.unpack(dest)?;
97                    }
98
99                    Ok::<_, io::Error>(())
100                })?;
101            } else {
102                archive.entries()?.try_for_each(|entry| {
103                    entry?.unpack_in(dest_dir)?;
104                    Ok::<_, io::Error>(())
105                })?;
106            }
107        }
108        Some("text/html") => {
109            return Err(UnpackError::SourceMovedOrDeleted);
110        }
111        Some(other) => {
112            return Err(UnpackError::UnsupportedFileType(other.to_string()));
113        }
114        None => {
115            return Err(UnpackError::UnknownMimeType);
116        }
117    }
118
119    if extract_nested_archive {
120        // If the source is an archive, luarocks will pack the source archive and the rockspec.
121        // So we need to unpack the source archive.
122        if let Some((nested_archive_path, mime_type)) = get_single_archive_entry(dest_dir)? {
123            {
124                let mut file = File::open(&nested_archive_path)?;
125                let mut buffer = Vec::new();
126                file.read_to_end(&mut buffer)?;
127                let file_name = nested_archive_path
128                    .file_name()
129                    .map(|os_str| os_str.to_string_lossy())
130                    .unwrap_or(nested_archive_path.to_string_lossy())
131                    .to_string();
132                unpack(
133                    mime_type,
134                    file,
135                    extract_nested_archive, // It might be a nested archive inside a .src.rock
136                    file_name,
137                    dest_dir,
138                    progress,
139                )
140                .await?;
141                fs::remove_file(nested_archive_path)?;
142            }
143        }
144    }
145    Ok(())
146}
147
148fn is_single_tar_directory<R: Read + Seek + Send>(reader: R) -> io::Result<bool> {
149    let tar = GzDecoder::new(reader);
150    let mut archive = tar::Archive::new(tar);
151
152    let entries: Vec<_> = archive
153        .entries()?
154        .filter_map(|entry| {
155            if entry.as_ref().ok()?.path().ok()?.file_name()? != "pax_global_header" {
156                Some(entry)
157            } else {
158                None
159            }
160        })
161        .try_collect()?;
162
163    if entries.is_empty() {
164        Ok(false)
165    } else {
166        let directory: PathBuf = entries[0].path()?.components().take(1).collect();
167
168        Ok(entries.into_iter().all(|entry| {
169            entry.path().is_ok_and(|path| {
170                path.to_str()
171                    .unwrap()
172                    .starts_with(directory.to_str().unwrap())
173            })
174        }))
175    }
176}
177
178fn get_single_archive_entry(dir: &Path) -> Result<Option<(PathBuf, Option<&str>)>, io::Error> {
179    let entries = std::fs::read_dir(dir)?
180        .filter_map(Result::ok)
181        .filter_map(|f| {
182            let f = f.path();
183            if f.extension()
184                .is_some_and(|ext| ext.to_string_lossy() != "rockspec")
185            {
186                Some(f)
187            } else {
188                None
189            }
190        })
191        .collect_vec();
192    if entries.len() != 1 {
193        return Ok(None);
194    }
195    let entry = entries.first().unwrap();
196    if !entry.is_file() {
197        return Ok(None);
198    }
199    if let mt @ Some(mime_type) =
200        infer::get_from_path(entry)?.map(|file_type| file_type.mime_type())
201    {
202        if matches!(
203            mime_type,
204            "application/zip" | "application/x-tar" | "application/gzip"
205        ) {
206            return Ok(Some((entry.clone(), mt)));
207        }
208    }
209    Ok(None)
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::progress::MultiProgress;
215    use std::fs::File;
216    use tempdir::TempDir;
217
218    use super::*;
219
220    #[tokio::test]
221    pub async fn test_unpack_src_rock() {
222        let test_rock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
223            .join("resources")
224            .join("test")
225            .join("luatest-0.2-1.src.rock");
226        let file = File::open(&test_rock_path).unwrap();
227        let dest = TempDir::new("lux-test").unwrap();
228        unpack_src_rock(
229            file,
230            dest.into_path(),
231            &Progress::Progress(MultiProgress::new().new_bar()),
232        )
233        .await
234        .unwrap();
235    }
236}