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