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