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