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