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