Skip to main content

lux_lib/operations/
unpack.rs

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