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