1use std::fs::{self, File};
9use std::io::{Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12use thiserror::Error;
13use tokio_util::sync::CancellationToken;
14
15use crate::fs::{RenameError, rename_noreplace, resolves_within, staging_dir};
16
17mod create;
18mod extract;
19mod parser_budget;
20#[cfg(test)]
21mod tests;
22
23pub use create::{create_archive, create_archive_cancellable};
24
25const MAX_ZIP_METADATA_READ_BYTES: u64 = 64 * 1024 * 1024;
27const MAX_TAR_METADATA_READ_BYTES: u64 = 64 * 1024 * 1024;
29const MAX_TAR_ENTRY_METADATA_READ_BYTES: u64 = 1024 * 1024;
30const MAX_XZ_DECODER_MEMORY: u64 = 256 * 1024 * 1024;
32const MAX_SYMLINK_TARGET_BYTES: u64 = 8 * 1024;
34
35pub fn open_zip<R: Read + Seek>(mut reader: R) -> zip::result::ZipResult<zip::ZipArchive<R>> {
43 use zip::read::{ArchiveOffset, Config};
44 let mut signature = [0; 4];
45 let at_start = reader.read_exact(&mut signature).is_ok()
46 && matches!(&signature, b"PK\x03\x04" | b"PK\x05\x06");
48 reader.seek(SeekFrom::Start(0))?;
49 let archive_offset = if at_start {
50 ArchiveOffset::Known(0)
51 } else {
52 ArchiveOffset::Detect
53 };
54 zip::ZipArchive::with_config(Config { archive_offset }, reader)
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct ExtractLimits {
60 pub max_total_bytes: u64,
62 pub max_entry_bytes: u64,
64 pub max_entries: u64,
66 pub max_ratio: f64,
68}
69
70impl Default for ExtractLimits {
71 fn default() -> Self {
72 Self {
73 max_total_bytes: 64 * 1024 * 1024 * 1024,
74 max_entry_bytes: 64 * 1024 * 1024 * 1024,
75 max_entries: 1_000_000,
76 max_ratio: 10_000.0,
77 }
78 }
79}
80
81#[derive(Debug, Error)]
83pub enum ArchiveError {
84 #[error("Archive operation cancelled before publication")]
85 Cancelled,
86
87 #[error("I/O error: {0}")]
88 Io(#[from] std::io::Error),
89
90 #[error("ZIP error: {0}")]
91 Zip(#[from] zip::result::ZipError),
92
93 #[error("Unsupported archive format: {0}")]
94 UnsupportedFormat(String),
95
96 #[error("Archive not found: {0}")]
97 NotFound(PathBuf),
98
99 #[error("Destination already exists: {0}")]
100 DestinationExists(PathBuf),
101
102 #[error("Archive extraction failed: {source}; {count} published entries could not be withdrawn from {destination}", count = .left_behind.len())]
105 ExtractionRollback {
106 destination: PathBuf,
107 #[source]
108 source: Box<ArchiveError>,
109 left_behind: Vec<PathBuf>,
110 },
111
112 #[error("Potential decompression bomb detected: {0}")]
113 DecompressionBomb(String),
114}
115
116pub type ArchiveResult<T> = Result<T, ArchiveError>;
118
119fn invalid_data(message: impl Into<String>) -> ArchiveError {
120 ArchiveError::Io(std::io::Error::new(
121 std::io::ErrorKind::InvalidData,
122 message.into(),
123 ))
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ArchiveFormat {
129 Zip,
130 Tar,
131 TarGz,
132 TarBz2,
133 TarXz,
134 TarZst,
135}
136
137impl ArchiveFormat {
138 pub const ALL: [Self; 6] = [
140 Self::Zip,
141 Self::Tar,
142 Self::TarGz,
143 Self::TarBz2,
144 Self::TarXz,
145 Self::TarZst,
146 ];
147
148 pub fn from_path(path: &Path) -> Option<Self> {
150 let name = path.file_name()?.to_str()?.to_lowercase();
151 Self::ALL.into_iter().find(|format| {
152 format
153 .extensions()
154 .iter()
155 .any(|extension| name.ends_with(extension))
156 })
157 }
158
159 pub fn extension(&self) -> &'static str {
161 self.extensions()[0]
162 }
163
164 fn extensions(&self) -> &'static [&'static str] {
166 match self {
167 Self::Zip => &[".zip"],
168 Self::Tar => &[".tar"],
169 Self::TarGz => &[".tar.gz", ".tgz"],
170 Self::TarBz2 => &[".tar.bz2", ".tbz2", ".tbz"],
171 Self::TarXz => &[".tar.xz", ".txz"],
172 Self::TarZst => &[".tar.zst", ".tzst"],
173 }
174 }
175}
176
177pub fn extract_archive(archive_path: &Path, destination: &Path) -> ArchiveResult<Vec<PathBuf>> {
193 extract_archive_with(
194 archive_path,
195 destination,
196 &ExtractLimits::default(),
197 &CancellationToken::new(),
198 )
199}
200
201pub fn extract_archive_cancellable(
203 archive_path: &Path,
204 destination: &Path,
205 cancel: &CancellationToken,
206) -> ArchiveResult<Vec<PathBuf>> {
207 extract_archive_with(archive_path, destination, &ExtractLimits::default(), cancel)
208}
209
210pub fn extract_archive_with(
212 archive_path: &Path,
213 destination: &Path,
214 limits: &ExtractLimits,
215 cancel: &CancellationToken,
216) -> ArchiveResult<Vec<PathBuf>> {
217 check_cancel(cancel)?;
218 let input = open_archive_input(archive_path)?;
219 let format = ArchiveFormat::from_path(archive_path).ok_or_else(|| {
220 ArchiveError::UnsupportedFormat(
221 archive_path
222 .extension()
223 .and_then(|extension| extension.to_str())
224 .unwrap_or("unknown")
225 .to_string(),
226 )
227 })?;
228 if let Ok(metadata) = fs::symlink_metadata(destination)
229 && !metadata.is_dir()
230 {
231 return Err(ArchiveError::Io(std::io::Error::new(
232 std::io::ErrorKind::AlreadyExists,
233 "extraction destination is not a directory",
234 )));
235 }
236 let parent = crate::fs::parent_or_current(destination);
237 fs::create_dir_all(parent)?;
238 let destination =
239 dunce::canonicalize(parent)?.join(destination.file_name().ok_or_else(|| {
240 std::io::Error::new(
241 std::io::ErrorKind::InvalidInput,
242 "extraction destination has no file name",
243 )
244 })?);
245
246 let staging = staging_dir(&destination, ".gravityfile-extract-")?;
247 let payload = staging.path().join("payload");
248 fs::create_dir(&payload)?;
249 let payload = dunce::canonicalize(&payload)?;
250
251 let decoded = extract::decode(format, input, &payload, limits, cancel)
252 .and_then(|entries| reject_escaping_links(&payload, cancel).map(|()| entries));
253 let entries = match decoded {
254 Ok(entries) => entries,
255 Err(_) if cancel.is_cancelled() => return Err(ArchiveError::Cancelled),
256 Err(error) => return Err(error),
257 };
258 check_cancel(cancel)?;
259 publish(&payload, &destination)?;
260 if let Err(error) = staging.close() {
261 tracing::warn!(%error, "extraction published; staging cleanup incomplete");
262 }
263 Ok(entries
264 .into_iter()
265 .filter_map(|path| Some(destination.join(path.strip_prefix(&payload).ok()?)))
266 .collect())
267}
268
269fn reject_escaping_links(root: &Path, cancel: &CancellationToken) -> ArchiveResult<()> {
271 let mut pending = vec![root.to_path_buf()];
272 while let Some(directory) = pending.pop() {
273 for entry in fs::read_dir(&directory)? {
274 check_cancel(cancel)?;
275 let entry = entry?;
276 let file_type = entry.file_type()?;
277 if file_type.is_dir() {
278 pending.push(entry.path());
279 } else if file_type.is_symlink() && !resolves_within(root, &entry.path())? {
280 let relative = entry
281 .path()
282 .strip_prefix(root)
283 .unwrap_or(&entry.path())
284 .to_path_buf();
285 return Err(invalid_data(format!(
286 "Symlink escapes destination: {}",
287 relative.display()
288 )));
289 }
290 }
291 }
292 Ok(())
293}
294
295fn publish(payload: &Path, destination: &Path) -> ArchiveResult<()> {
301 match rename_noreplace(payload, destination) {
302 Ok(()) => return Ok(()),
303 Err(RenameError::DestinationExists) => {}
304 Err(error) => return Err(std::io::Error::from(error).into()),
305 }
306 if !fs::symlink_metadata(destination)?.is_dir() {
307 return Err(ArchiveError::DestinationExists(destination.to_path_buf()));
308 }
309
310 let mut moves = Vec::new();
312 let mut pending = vec![(payload.to_path_buf(), destination.to_path_buf())];
313 while let Some((staged_dir, target_dir)) = pending.pop() {
314 for entry in fs::read_dir(&staged_dir)? {
315 let entry = entry?;
316 let staged = entry.path();
317 let target = target_dir.join(entry.file_name());
318 match fs::symlink_metadata(&target) {
319 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
320 moves.push((staged, target));
321 }
322 Ok(existing) if existing.is_dir() && entry.file_type()?.is_dir() => {
323 pending.push((staged, target));
324 }
325 Ok(_) => {
326 return Err(ArchiveError::Io(std::io::Error::new(
327 std::io::ErrorKind::AlreadyExists,
328 format!("archive entry conflicts with '{}'", target.display()),
329 )));
330 }
331 Err(error) => return Err(error.into()),
332 }
333 }
334 }
335
336 let mut published: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(moves.len());
337 for (staged, target) in moves {
338 if let Err(error) = rename_noreplace(&staged, &target) {
339 let left_behind: Vec<PathBuf> = published
340 .iter()
341 .rev()
342 .filter(|(staged, target)| rename_noreplace(target, staged).is_err())
343 .map(|(_, target)| target.clone())
344 .collect();
345 let error = ArchiveError::Io(error.into());
346 return Err(if left_behind.is_empty() {
347 error
348 } else {
349 ArchiveError::ExtractionRollback {
350 destination: destination.to_path_buf(),
351 source: Box::new(error),
352 left_behind,
353 }
354 });
355 }
356 published.push((staged, target));
357 }
358 Ok(())
359}
360
361struct ArchiveInput<R> {
363 reader: R,
364 cancel: CancellationToken,
365}
366
367impl<R> ArchiveInput<R> {
368 fn new(reader: R, cancel: &CancellationToken) -> Self {
369 Self {
370 reader,
371 cancel: cancel.clone(),
372 }
373 }
374
375 fn check(&self) -> std::io::Result<()> {
376 if self.cancel.is_cancelled() {
377 Err(std::io::Error::other("archive extraction cancelled"))
378 } else {
379 Ok(())
380 }
381 }
382}
383
384impl<R: Read> Read for ArchiveInput<R> {
385 fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
386 self.check()?;
387 self.reader.read(bytes)
388 }
389}
390
391impl<R: std::io::Seek> std::io::Seek for ArchiveInput<R> {
392 fn seek(&mut self, position: std::io::SeekFrom) -> std::io::Result<u64> {
393 self.check()?;
394 self.reader.seek(position)
395 }
396}
397
398fn open_archive_input(path: &Path) -> ArchiveResult<File> {
401 let mut options = fs::OpenOptions::new();
402 options.read(true);
403 #[cfg(unix)]
404 {
405 use std::os::unix::fs::OpenOptionsExt;
406 options.custom_flags(libc::O_NONBLOCK);
407 }
408 let file = options.open(path).map_err(|error| {
409 if error.kind() == std::io::ErrorKind::NotFound {
410 ArchiveError::NotFound(path.to_path_buf())
411 } else {
412 ArchiveError::Io(error)
413 }
414 })?;
415 if !file.metadata()?.is_file() {
416 return Err(ArchiveError::Io(std::io::Error::new(
417 std::io::ErrorKind::InvalidInput,
418 "Archive input must be a regular file",
419 )));
420 }
421 Ok(file)
422}
423
424fn check_cancel(cancel: &CancellationToken) -> ArchiveResult<()> {
425 if cancel.is_cancelled() {
426 Err(ArchiveError::Cancelled)
427 } else {
428 Ok(())
429 }
430}