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