1use crate::io::{files_all, standard_project_folder, ApiResult, PathConversion};
3use crate::prelude::{create_dir_all, io, remove_file, ErrorKind, File, OpenOptions, Path, PathBuf, Read, Write};
4use crate::util::constants::app::{ARCHIVE_INFERENCE_BYTES, MAX_ARCHIVE_ENTRIES, MAX_ARCHIVE_EXPANDED_BYTES};
5use crate::util::MimeType;
6use color_eyre::Report;
7use core::fmt;
8use core::iter::once;
9use flate2::read::GzDecoder;
10use flate2::write::GzEncoder;
11use flate2::Compression;
12use sevenz_rust2::{ArchiveEntry, ArchiveReader, ArchiveWriter, Password};
13use tar::{Archive as TarArchive, Builder as TarBuilder};
14use zip::write::SimpleFileOptions;
15use zip::{ZipArchive, ZipWriter};
16
17pub trait ArchiveCreation {
19 fn archive_7z(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
21 fn archive_tar(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
23 fn archive_tar_gzip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
25 fn archive_zip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
27}
28trait ArchiveEntryExt {
29 fn extract(&self, reader: &mut dyn Read, root: &Path) -> Result<bool, sevenz_rust2::Error>;
30 fn validate(&self, seen: Vec<PathBuf>) -> ApiResult<Vec<PathBuf>>;
31}
32pub trait ArchiveExtraction {
34 fn extract_7z(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
36 fn extract_tar(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
38 fn extract_tar_gzip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
40 fn extract_zip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
42}
43pub trait ArchiveFormat {
45 fn archive(&self, candidate: ArchiveCandidate, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
47 fn extract(&self, candidate: ArchiveCandidate, destination: Option<PathBuf>) -> ApiResult<PathBuf>;
49}
50#[derive(Debug)]
51enum ArchiveError {
52 DestinationInsideSource,
53 DestinationInvalid,
54 DestinationMissingFilename,
55 DuplicatePath(PathBuf),
56 EntryExpandedSizeLimit,
57 ExpandedSizeLimit,
58 ExpandedSizeOverflow,
59 FilePathEmpty,
60 FormatInference(PathBuf),
61 InspectArchive { path: PathBuf, reason: Box<str> },
62 InspectSourceChild { path: PathBuf, reason: Box<str> },
63 OutputInvalid,
64 ReadArchive { path: PathBuf, reason: Box<str> },
65 ReplaceDestination(Box<str>),
66 ResolveSource(Box<str>),
67 SevenZip { operation: &'static str, reason: Box<str> },
68 SourceLink(PathBuf),
69 TooManyEntries,
70 UnsafeDestinationComponent(PathBuf),
71 UnsafeZipPath(Box<str>),
72 UnsupportedFormat(MimeType),
73 UnsupportedTarEntry,
74 ZipLink(PathBuf),
75}
76#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct ArchiveCandidate(PathBuf);
79impl ArchiveCandidate {
80 pub fn archive(self, destination: Option<PathBuf>, mime_type: MimeType) -> ApiResult<PathBuf> {
82 mime_type.archive(self, destination)
83 }
84 pub fn extract(self, destination: Option<PathBuf>, archive_format: Option<MimeType>) -> ApiResult<PathBuf> {
86 archive_format
87 .map_or_else(|| self.infer(), Ok)
88 .and_then(|format| format.extract(self, destination))
89 }
90 fn append_tar<W: Write>(self, writer: W) -> ApiResult<()> {
91 self.relative_children().and_then(|children| {
92 children
93 .into_iter()
94 .try_fold(TarBuilder::new(writer), |mut archive, (path, relative)| {
95 archive.append_path_with_name(path, relative).map(|()| archive).map_err(Into::into)
96 })
97 .and_then(|mut archive| archive.finish().map_err(Into::into))
98 })
99 }
100 fn infer(&self) -> ApiResult<MimeType> {
101 File::open(&self.0)
102 .map_err(|why| ArchiveError::ReadArchive {
103 path: self.0.clone(),
104 reason: why.to_string().into(),
105 })
106 .and_then(|file| {
107 let mut header = Vec::new();
108 file.take(ARCHIVE_INFERENCE_BYTES)
109 .read_to_end(&mut header)
110 .map(|_| header)
111 .map_err(|why| ArchiveError::InspectArchive {
112 path: self.0.clone(),
113 reason: why.to_string().into(),
114 })
115 })
116 .map_err(Into::into)
117 .and_then(|header| MimeType::infer(&header).ok_or_else(|| ArchiveError::FormatInference(self.0.clone()).into()))
118 }
119 fn prepare_output(&self, destination: Option<PathBuf>, format: MimeType) -> ApiResult<(File, PathBuf)> {
120 let output = destination.unwrap_or_else(|| self.0.with_extension(format.file_type()));
121 self.0
122 .canonicalize()
123 .map_err(|why| Report::from(ArchiveError::ResolveSource(why.to_string().into())))
124 .and_then(|root| {
125 let parent = output
126 .parent()
127 .filter(|path| !path.as_os_str().is_empty())
128 .unwrap_or_else(|| Path::new("."));
129 parent
130 .canonicalize()
131 .map_err(Into::into)
132 .and_then(|parent| {
133 output
134 .file_name()
135 .map(|name| parent.join(name))
136 .ok_or_else(|| ArchiveError::DestinationMissingFilename.into())
137 })
138 .map(|output| (root, output))
139 })
140 .and_then(|(root, resolved)| match resolved.starts_with(&root) {
141 | true => Err(ArchiveError::DestinationInsideSource.into()),
142 | false => prepare_archive_destination(&output).map(|file| (file, output)),
143 })
144 }
145 fn relative_children(&self) -> ApiResult<Vec<(PathBuf, PathBuf)>> {
146 self.0
147 .canonicalize()
148 .map_err(|why| Report::from(ArchiveError::ResolveSource(why.to_string().into())))
149 .and_then(|root| {
150 files_all(root.clone(), None::<Vec<String>>)
151 .into_iter()
152 .map(|path| {
153 path.symlink_metadata()
154 .map_err(|why| {
155 Report::from(ArchiveError::InspectSourceChild {
156 path: path.clone(),
157 reason: why.to_string().into(),
158 })
159 })
160 .and_then(|metadata| match metadata.file_type().is_symlink() {
161 | true => Err(ArchiveError::SourceLink(path.clone()).into()),
162 | false => path.canonicalize().map_err(Into::into).and_then(|absolute| {
163 absolute
164 .strip_prefix(&root)
165 .map(Path::to_path_buf)
166 .map(|relative| (absolute, relative))
167 .map_err(Into::into)
168 }),
169 })
170 })
171 .collect()
172 })
173 }
174}
175impl ArchiveCreation for ArchiveCandidate {
176 fn archive_7z(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
177 self.prepare_output(destination, MimeType::SevenZip).and_then(|(file, output)| {
178 ArchiveWriter::new(file)
179 .map_err(|why| {
180 Report::from(ArchiveError::SevenZip {
181 operation: "create 7z archive",
182 reason: why.to_string().into(),
183 })
184 })
185 .and_then(|writer| {
186 self.relative_children().and_then(|children| {
187 children
188 .into_iter()
189 .try_fold(writer, |mut writer, (path, relative)| {
190 let entry = ArchiveEntry::from_path(&path, relative.to_string_lossy().replace('\\', "/"));
191 path.is_file()
192 .then(|| File::open(&path))
193 .transpose()
194 .map_err(Into::into)
195 .and_then(|reader| match writer.push_archive_entry(entry, reader) {
196 | Ok(_) => Ok(writer),
197 | Err(why) => Err(ArchiveError::SevenZip {
198 operation: "add 7z entry",
199 reason: why.to_string().into(),
200 }
201 .into()),
202 })
203 })
204 .and_then(|writer| {
205 writer.finish().map_err(|why| {
206 ArchiveError::SevenZip {
207 operation: "finish 7z archive",
208 reason: why.to_string().into(),
209 }
210 .into()
211 })
212 })
213 })
214 })
215 .map(|_| output)
216 })
217 }
218 fn archive_tar(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
219 self.prepare_output(destination, MimeType::Tar)
220 .and_then(|(file, output)| self.append_tar(file).map(|()| output))
221 }
222 fn archive_tar_gzip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
223 self.prepare_output(destination, MimeType::Gzip)
224 .and_then(|(file, output)| self.append_tar(GzEncoder::new(file, Compression::default())).map(|()| output))
225 }
226 fn archive_zip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
227 self.prepare_output(destination, MimeType::Zip).and_then(|(file, output)| {
228 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
229 self.relative_children()
230 .and_then(|children| {
231 children
232 .into_iter()
233 .try_fold(ZipWriter::new(file), |mut writer, (path, relative)| match path.is_dir() {
234 | true => writer.add_directory_from_path(relative, options).map(|()| writer).map_err(Into::into),
235 | false => writer
236 .start_file_from_path(relative, options)
237 .map_err(Into::into)
238 .and_then(|()| File::open(path).map_err(Into::into))
239 .and_then(|mut input| io::copy(&mut input, &mut writer).map_err(Into::into))
240 .map(|_| writer),
241 })
242 })
243 .and_then(|writer| writer.finish().map_err(Into::into))
244 .map(|_| output)
245 })
246 }
247}
248impl ArchiveExtraction for ArchiveCandidate {
249 fn extract_7z(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
250 extraction_root(destination).and_then(|root| {
251 File::open(&self.0)
252 .map_err(Into::into)
253 .and_then(|file| {
254 ArchiveReader::new(file, Password::empty()).map_err(|why| {
255 ArchiveError::SevenZip {
256 operation: "read 7z archive",
257 reason: why.to_string().into(),
258 }
259 .into()
260 })
261 })
262 .and_then(|mut archive| {
263 let validation = {
264 let entries = archive.archive().files.as_slice();
265 let size = entries.iter().try_fold(0_u64, |total, entry| {
266 total.checked_add(entry.size()).ok_or_else(|| ArchiveError::ExpandedSizeOverflow.into())
267 });
268 match (entries.len() > MAX_ARCHIVE_ENTRIES, size) {
269 | (true, _) => Err(ArchiveError::TooManyEntries.into()),
270 | (_, Ok(size)) if size > MAX_ARCHIVE_EXPANDED_BYTES => Err(ArchiveError::ExpandedSizeLimit.into()),
271 | (_, Err(why)) => Err(why),
272 | _ => entries
273 .iter()
274 .try_fold(Vec::new(), |seen, entry| entry.validate(seen))
275 .and_then(|paths| paths.iter().try_for_each(|relative| safe_target(&root, relative).map(|_| ()))),
276 }
277 };
278 validation.and_then(|()| {
279 archive.for_each_entries(|entry, reader| entry.extract(reader, &root)).map_err(|why| {
280 ArchiveError::SevenZip {
281 operation: "extract 7z archive",
282 reason: why.to_string().into(),
283 }
284 .into()
285 })
286 })
287 })
288 .map(|()| root)
289 })
290 }
291 fn extract_tar(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
292 File::open(self.0)
293 .map_err(Into::into)
294 .and_then(|file| extract_tar_reader(file, destination))
295 }
296 fn extract_tar_gzip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
297 File::open(self.0)
298 .map(GzDecoder::new)
299 .map_err(Into::into)
300 .and_then(|decoder| extract_tar_reader(decoder, destination))
301 }
302 fn extract_zip(self, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
303 extraction_root(destination).and_then(|root| {
304 File::open(&self.0)
305 .map_err(Into::into)
306 .and_then(|file| ZipArchive::new(file).map_err(Into::into))
307 .and_then(|mut archive| match archive.len() > MAX_ARCHIVE_ENTRIES {
308 | true => Err(ArchiveError::TooManyEntries.into()),
309 | false => (0..archive.len()).try_fold(Vec::new(), |seen, index| {
310 archive.by_index(index).map_err(Into::into).and_then(|mut entry| {
311 entry
312 .enclosed_name()
313 .ok_or_else(|| ArchiveError::UnsafeZipPath(entry.name().into()).into())
314 .and_then(|path| path.relative())
315 .and_then(|relative| validate_entry(&root, seen, relative, entry.is_dir(), entry.size()))
316 .and_then(|(seen, target)| {
317 let mode = entry.unix_mode().unwrap_or_default() & 0o170000;
318 match mode == 0o120000 {
319 | true => Err(ArchiveError::ZipLink(target).into()),
320 | false => {
321 let is_directory = entry.is_dir();
322 write_entry(&mut entry, &target, is_directory).map(|()| seen)
323 }
324 }
325 })
326 })
327 }),
328 })
329 .map(|_| root)
330 })
331 }
332}
333impl From<&Path> for ArchiveCandidate {
334 fn from(value: &Path) -> Self {
335 Self(value.to_path_buf())
336 }
337}
338impl From<PathBuf> for ArchiveCandidate {
339 fn from(value: PathBuf) -> Self {
340 Self(value)
341 }
342}
343impl ArchiveEntryExt for ArchiveEntry {
344 fn extract(&self, reader: &mut dyn Read, root: &Path) -> Result<bool, sevenz_rust2::Error> {
345 Path::new(self.name())
346 .relative()
347 .and_then(|relative| safe_target(root, &relative).map(|target| (relative, target)))
348 .and_then(|(relative, target)| write_entry(reader, &target, self.is_directory()).map(|()| relative))
349 .map(|_| true)
350 .map_err(|why| sevenz_rust2::Error::Other(why.to_string().into()))
351 }
352 fn validate(&self, seen: Vec<PathBuf>) -> ApiResult<Vec<PathBuf>> {
353 Path::new(self.name()).relative().and_then(|relative| match seen.contains(&relative) {
354 | true => Err(ArchiveError::DuplicatePath(relative).into()),
355 | false => Ok(seen.into_iter().chain(once(relative)).collect()),
356 })
357 }
358}
359impl core::error::Error for ArchiveError {}
360impl fmt::Display for ArchiveError {
361 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
362 match self {
363 | Self::DestinationInsideSource => write!(formatter, "Archive destination cannot be inside its source directory"),
364 | Self::DestinationInvalid => write!(formatter, "Archive destination is not a regular directory"),
365 | Self::DestinationMissingFilename => write!(formatter, "Archive destination must name a file"),
366 | Self::DuplicatePath(path) => write!(formatter, "Duplicate archive path: {}", path.display()),
367 | Self::EntryExpandedSizeLimit => write!(formatter, "Archive entry expands beyond the supported size limit"),
368 | Self::ExpandedSizeLimit => write!(formatter, "Archive expands beyond the supported size limit"),
369 | Self::ExpandedSizeOverflow => write!(formatter, "Archive expanded size overflow"),
370 | Self::FilePathEmpty => write!(formatter, "Archive file path cannot be empty"),
371 | Self::FormatInference(path) => write!(formatter, "Unable to infer archive format for {}", path.display()),
372 | Self::InspectArchive { path, reason } => write!(formatter, "Failed to inspect archive {} — {reason}", path.display()),
373 | Self::InspectSourceChild { path, reason } => {
374 write!(formatter, "Failed to inspect archive source child {} — {reason}", path.display())
375 }
376 | Self::OutputInvalid => write!(formatter, "Archive destination exists and is not a regular file"),
377 | Self::ReadArchive { path, reason } => write!(formatter, "Failed to read archive {} — {reason}", path.display()),
378 | Self::ReplaceDestination(reason) => write!(formatter, "Failed to replace archive destination — {reason}"),
379 | Self::ResolveSource(reason) => write!(formatter, "Failed to resolve archive source — {reason}"),
380 | Self::SevenZip { operation, reason } => write!(formatter, "Failed to {operation} — {reason}"),
381 | Self::SourceLink(path) => write!(formatter, "Archive source links are not supported: {}", path.display()),
382 | Self::TooManyEntries => write!(formatter, "Archive contains too many entries"),
383 | Self::UnsafeDestinationComponent(path) => {
384 write!(formatter, "Unsafe archive destination component: {}", path.display())
385 }
386 | Self::UnsafeZipPath(path) => write!(formatter, "Unsafe ZIP path: {path}"),
387 | Self::UnsupportedFormat(format) => write!(formatter, "Unsupported archive format: {format}"),
388 | Self::UnsupportedTarEntry => write!(formatter, "Unsupported TAR entry"),
389 | Self::ZipLink(path) => write!(formatter, "ZIP links are not supported: {}", path.display()),
390 }
391 }
392}
393impl ArchiveFormat for MimeType {
394 fn archive(&self, source: ArchiveCandidate, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
395 match self {
396 | Self::Gzip => source.archive_tar_gzip(destination),
397 | Self::SevenZip => source.archive_7z(destination),
398 | Self::Tar => source.archive_tar(destination),
399 | Self::Zip => source.archive_zip(destination),
400 | _ => Err(ArchiveError::UnsupportedFormat(self.clone()).into()),
401 }
402 }
403 fn extract(&self, source: ArchiveCandidate, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
404 match self {
405 | Self::Gzip => source.extract_tar_gzip(destination),
406 | Self::SevenZip => source.extract_7z(destination),
407 | Self::Tar => source.extract_tar(destination),
408 | Self::Zip => source.extract_zip(destination),
409 | _ => Err(ArchiveError::UnsupportedFormat(self.clone()).into()),
410 }
411 }
412}
413pub fn archive(source: PathBuf, destination: Option<PathBuf>, mime_type: MimeType) -> ApiResult<PathBuf> {
415 ArchiveCandidate::from(source).archive(destination, mime_type)
416}
417pub fn extract(source: PathBuf, destination: Option<PathBuf>, archive_format: Option<MimeType>) -> ApiResult<PathBuf> {
419 ArchiveCandidate::from(source).extract(destination, archive_format)
420}
421fn extract_tar_reader<R: Read>(reader: R, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
422 extraction_root(destination).and_then(|root| {
423 TarArchive::new(reader)
424 .entries()
425 .map_err(Into::into)
426 .and_then(|entries| {
427 entries
428 .enumerate()
429 .try_fold(Vec::new(), |seen, (index, entry)| match index >= MAX_ARCHIVE_ENTRIES {
430 | true => Err(ArchiveError::TooManyEntries.into()),
431 | false => entry.map_err(Into::into).and_then(|mut entry| {
432 entry
433 .path()
434 .map_err(Into::into)
435 .and_then(|path| path.as_ref().relative())
436 .and_then(|relative| {
437 let is_directory = entry.header().entry_type().is_dir();
438 let is_file = entry.header().entry_type().is_file();
439 match (relative.as_os_str().is_empty(), is_directory, is_file) {
440 | (true, true, _) => Ok((seen, root.clone(), true)),
441 | (false, true, _) | (false, _, true) => validate_entry(&root, seen, relative, is_directory, entry.size())
442 .map(|(seen, target)| (seen, target, is_directory)),
443 | _ => Err(ArchiveError::UnsupportedTarEntry.into()),
444 }
445 })
446 .and_then(|(seen, target, is_directory)| write_entry(&mut entry, &target, is_directory).map(|()| seen))
447 }),
448 })
449 })
450 .map(|_| root)
451 })
452}
453fn extraction_root(destination: Option<PathBuf>) -> ApiResult<PathBuf> {
454 let root = destination.unwrap_or_else(|| standard_project_folder("extract", None));
455 match root.symlink_metadata() {
456 | Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Err(ArchiveError::DestinationInvalid.into()),
457 | Ok(_) => Ok(root),
458 | Err(why) if why.kind() == ErrorKind::NotFound => create_dir_all(&root).map(|()| root).map_err(Into::into),
459 | Err(why) => Err(why.into()),
460 }
461}
462fn prepare_archive_destination(path: &Path) -> ApiResult<File> {
463 match path.symlink_metadata() {
464 | Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(ArchiveError::OutputInvalid.into()),
465 | Ok(_) => remove_file(path).map_err(|why| ArchiveError::ReplaceDestination(why.to_string().into()).into()),
466 | Err(why) if why.kind() == ErrorKind::NotFound => Ok(()),
467 | Err(why) => Err(why.into()),
468 }
469 .and_then(|()| OpenOptions::new().write(true).create_new(true).open(path).map_err(Into::into))
470}
471fn safe_target(root: &Path, relative: &Path) -> ApiResult<PathBuf> {
472 relative
473 .ancestors()
474 .skip(1)
475 .map(|parent| root.join(parent))
476 .try_for_each(|parent| match parent.symlink_metadata() {
477 | Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Err(ArchiveError::UnsafeDestinationComponent(parent).into()),
478 | Ok(_) => Ok(()),
479 | Err(why) if why.kind() == ErrorKind::NotFound => Ok(()),
480 | Err(why) => Err(why.into()),
481 })
482 .map(|()| root.join(relative))
483}
484fn validate_entry(root: &Path, seen: Vec<PathBuf>, relative: PathBuf, is_directory: bool, size: u64) -> ApiResult<(Vec<PathBuf>, PathBuf)> {
485 match (relative.as_os_str().is_empty(), size > MAX_ARCHIVE_EXPANDED_BYTES) {
486 | (true, _) if !is_directory => Err(ArchiveError::FilePathEmpty.into()),
487 | (_, true) => Err(ArchiveError::EntryExpandedSizeLimit.into()),
488 | _ if seen.contains(&relative) => Err(ArchiveError::DuplicatePath(relative).into()),
489 | _ => safe_target(root, &relative).map(|target| (seen.into_iter().chain(once(relative)).collect(), target)),
490 }
491}
492fn write_entry(reader: &mut dyn Read, target: &Path, is_directory: bool) -> ApiResult<()> {
493 match is_directory {
494 | true => create_dir_all(target).map_err(Into::into),
495 | false => target
496 .parent()
497 .map_or(Ok(()), |parent| create_dir_all(parent).map_err(Into::into))
498 .and_then(|()| OpenOptions::new().write(true).create_new(true).open(target).map_err(Into::into))
499 .and_then(|mut output| io::copy(reader, &mut output).map(|_| ()).map_err(Into::into)),
500 }
501}