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            let file_name = nested_archive_path
83                .file_name()
84                .map(|os_str| os_str.to_string_lossy())
85                .unwrap_or(nested_archive_path.to_string_lossy())
86                .to_string();
87            let buffer = fs::tokio::read(&nested_archive_path).await?;
88            unpack(
89                mime_type,
90                Cursor::new(buffer),
91                extract_nested_archive, // It might be a nested archive inside a .src.rock
92                file_name,
93                &dest_dir,
94            )
95            .await?;
96            fs::tokio::remove_file(&nested_archive_path).await?;
97        }
98    }
99    Ok(())
100}
101
102fn extract_archive<R: Read + Seek + Send>(
103    mime_type: Option<&str>,
104    reader: R,
105    extract_nested_archive: bool,
106    dest_dir: &Path,
107) -> Result<(), UnpackError> {
108    match mime_type {
109        Some("application/zip") => {
110            let mut archive = zip::ZipArchive::new(reader)?;
111            archive.extract(dest_dir)?;
112        }
113        Some("application/x-tar") => {
114            let mut archive = tar::Archive::new(reader);
115            archive.unpack(dest_dir)?;
116        }
117        Some("application/gzip") => {
118            let mut bufreader = BufReader::new(reader);
119
120            let extract_subdirectory =
121                extract_nested_archive && is_single_tar_directory(&mut bufreader)?;
122
123            bufreader.rewind()?;
124            let tar = GzDecoder::new(bufreader);
125            let mut archive = tar::Archive::new(tar);
126
127            if extract_subdirectory {
128                archive.entries()?.try_for_each(|entry| {
129                    let mut entry = entry?;
130
131                    let path: PathBuf = entry.path()?.components().skip(1).collect();
132                    if path.components().count() > 0 {
133                        let dest = dest_dir.join(path);
134                        if let Some(dest_parent_dir) = dest.parent() {
135                            fs::sync::create_dir_all(dest_parent_dir).map_err(io::Error::other)?;
136                        }
137                        entry.unpack(dest)?;
138                    }
139
140                    Ok::<_, io::Error>(())
141                })?;
142            } else {
143                archive.entries()?.try_for_each(|entry| {
144                    entry?.unpack_in(dest_dir)?;
145                    Ok::<_, io::Error>(())
146                })?;
147            }
148        }
149        Some("text/html") => {
150            return Err(UnpackError::SourceMovedOrDeleted);
151        }
152        Some(other) => {
153            return Err(UnpackError::UnsupportedFileType(other.to_string()));
154        }
155        None => {
156            return Err(UnpackError::UnknownMimeType);
157        }
158    }
159
160    Ok(())
161}
162
163fn is_single_tar_directory<R: Read + Seek + Send>(reader: R) -> io::Result<bool> {
164    let tar = GzDecoder::new(reader);
165    let mut archive = tar::Archive::new(tar);
166
167    let entries: Vec<_> = archive
168        .entries()?
169        .filter_map(|entry| {
170            if entry.as_ref().ok()?.path().ok()?.file_name()? != "pax_global_header" {
171                Some(entry)
172            } else {
173                None
174            }
175        })
176        .try_collect()?;
177
178    if entries.is_empty() {
179        Ok(false)
180    } else {
181        let directory: PathBuf = entries[0].path()?.components().take(1).collect();
182
183        Ok(entries.into_iter().all(|entry| {
184            entry.path().is_ok_and(|path| {
185                path.to_slash_lossy()
186                    .starts_with(&directory.to_slash_lossy().to_string())
187            })
188        }))
189    }
190}
191
192fn get_single_archive_entry(dir: &Path) -> Result<Option<(PathBuf, Option<&str>)>, io::Error> {
193    let entries = fs::sync::read_dir(dir)
194        .map_err(io::Error::other)?
195        .filter_map(Result::ok)
196        .filter_map(|f| {
197            let f = f.path();
198            if f.extension()
199                .is_some_and(|ext| ext.to_string_lossy() != "rockspec")
200            {
201                Some(f)
202            } else {
203                None
204            }
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::File;
232
233    use super::*;
234
235    #[tokio::test]
236    pub async fn test_unpack_src_rock() {
237        let test_rock_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
238            .join("resources")
239            .join("test")
240            .join("luatest-0.2-1.src.rock");
241        let file = File::open(&test_rock_path).unwrap();
242        let dest = TempDir::new().unwrap();
243        unpack_src_rock(file, dest.to_path_buf()).await.unwrap();
244    }
245}