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