Skip to main content

lux_lib/operations/
unpack.rs

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