use std::{
collections::BTreeSet,
io::Read,
path::{Component, Path, PathBuf},
pin::Pin,
task::{Context, Poll},
};
use async_compression::tokio::bufread::{GzipDecoder, ZstdDecoder};
use tokio::io::{AsyncRead, BufReader, ReadBuf};
use tokio_tar as tar;
use super::OVERRIDE_XATTR_KEY;
use crate::{
error::{ImageError, ImageResult},
progress::{PullProgress, PullProgressSender},
};
const OVERRIDE_STAT_VERSION: u8 = 1;
const MAX_TOTAL_SIZE: u64 = 10 * 1024 * 1024 * 1024;
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024 * 1024;
const MAX_ENTRY_COUNT: u64 = 1_000_000;
const MAX_PATH_DEPTH: usize = 128;
const EXTRACT_PROGRESS_INTERVAL: u64 = 64 * 1024;
#[cfg(target_os = "linux")]
const S_IFREG: u32 = libc::S_IFREG;
#[cfg(target_os = "macos")]
const S_IFREG: u32 = libc::S_IFREG as u32;
#[cfg(target_os = "linux")]
const S_IFDIR: u32 = libc::S_IFDIR;
#[cfg(target_os = "macos")]
const S_IFDIR: u32 = libc::S_IFDIR as u32;
#[cfg(target_os = "linux")]
const S_IFLNK: u32 = libc::S_IFLNK;
#[cfg(target_os = "macos")]
const S_IFLNK: u32 = libc::S_IFLNK as u32;
#[cfg(target_os = "linux")]
const S_IFBLK: u32 = libc::S_IFBLK;
#[cfg(target_os = "macos")]
const S_IFBLK: u32 = libc::S_IFBLK as u32;
#[cfg(target_os = "linux")]
const S_IFCHR: u32 = libc::S_IFCHR;
#[cfg(target_os = "macos")]
const S_IFCHR: u32 = libc::S_IFCHR as u32;
#[cfg(target_os = "linux")]
const S_IFIFO: u32 = libc::S_IFIFO;
#[cfg(target_os = "macos")]
const S_IFIFO: u32 = libc::S_IFIFO as u32;
pub(crate) struct ExtractionResult {
pub implicit_dirs: Vec<PathBuf>,
}
struct DeferredHardlink {
path: PathBuf,
target: PathBuf,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LayerCompression {
Plain,
Gzip,
Zstd,
}
struct CountingReader<R> {
inner: R,
bytes_read: u64,
total_bytes: u64,
last_report: u64,
sender: PullProgressSender,
layer_index: usize,
}
impl<R: AsyncRead + Unpin> AsyncRead for CountingReader<R> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
let before = buf.filled().len();
let result = Pin::new(&mut this.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let n = buf.filled().len() - before;
this.bytes_read += n as u64;
if this.bytes_read - this.last_report >= EXTRACT_PROGRESS_INTERVAL {
this.last_report = this.bytes_read;
this.sender.send(PullProgress::LayerExtractProgress {
layer_index: this.layer_index,
bytes_read: this.bytes_read,
total_bytes: this.total_bytes,
});
}
}
result
}
}
pub(crate) async fn extract_layer(
tar_path: &Path,
dest: &Path,
media_type: Option<&str>,
progress: Option<&PullProgressSender>,
layer_index: usize,
) -> ImageResult<ExtractionResult> {
use tar::Archive;
let compression = detect_layer_compression(tar_path, media_type)?;
let file = tokio::fs::File::open(tar_path)
.await
.map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("failed to open tarball: {e}"),
source: Some(Box::new(e)),
})?;
let base_reader: Box<dyn AsyncRead + Unpin + Send> = if let Some(sender) = progress {
let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0);
Box::new(CountingReader {
inner: file,
bytes_read: 0,
total_bytes: file_size,
last_report: 0,
sender: sender.clone(),
layer_index,
})
} else {
Box::new(file)
};
let archive_reader: Box<dyn AsyncRead + Unpin + Send> = match compression {
LayerCompression::Plain => Box::new(BufReader::new(base_reader)),
LayerCompression::Gzip => Box::new(BufReader::new(GzipDecoder::new(BufReader::new(
base_reader,
)))),
LayerCompression::Zstd => Box::new(BufReader::new(ZstdDecoder::new(BufReader::new(
base_reader,
)))),
};
let mut archive = Archive::new(archive_reader);
let mut deferred_hardlinks: Vec<DeferredHardlink> = Vec::new();
let mut implicit_dirs: BTreeSet<PathBuf> = BTreeSet::new();
let mut total_size: u64 = 0;
let mut entry_count: u64 = 0;
let mut entries = archive.entries().map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("failed to read tar entries: {e}"),
source: Some(Box::new(e)),
})?;
use futures::StreamExt;
while let Some(entry_result) = entries.next().await {
let mut entry = entry_result.map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("failed to read tar entry: {e}"),
source: Some(Box::new(e)),
})?;
entry_count += 1;
if entry_count > MAX_ENTRY_COUNT {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("exceeded max entry count ({MAX_ENTRY_COUNT})"),
source: None,
});
}
let header = entry.header().clone();
let entry_path = entry
.path()
.map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("invalid entry path: {e}"),
source: Some(Box::new(e)),
})?
.into_owned();
let full_path = validate_entry_path(dest, &entry_path, tar_path)?;
let uid = header.uid().unwrap_or(0) as u32;
let gid = header.gid().unwrap_or(0) as u32;
let tar_mode = header.mode().unwrap_or(0o644);
let size = header.size().unwrap_or(0);
let entry_type = header.entry_type();
if entry_type == tar::EntryType::Link {
if let Ok(Some(link_target)) = entry.link_name() {
let target_full = validate_entry_path(dest, &link_target, tar_path)?;
deferred_hardlinks.push(DeferredHardlink {
path: full_path,
target: target_full,
});
}
continue;
}
if entry_type == tar::EntryType::Directory {
if !full_path.exists() {
std::fs::create_dir_all(&full_path).map_err(|e| extraction_err(tar_path, e))?;
}
set_host_permissions(&full_path, 0o700)?;
let mode = S_IFDIR | (tar_mode & 0o7777);
set_override_stat(&full_path, uid, gid, mode, 0)?;
clear_implicit_entry(&full_path, dest, &mut implicit_dirs);
} else if entry_type == tar::EntryType::Symlink {
let link_target = entry
.link_name()
.map_err(|e| extraction_err(tar_path, e))?
.map(|p| p.into_owned())
.unwrap_or_default();
ensure_parent_dir(&full_path, dest, &mut implicit_dirs)?;
let mode = S_IFLNK | 0o777;
if cfg!(target_os = "linux") {
let _ = std::fs::remove_dir_all(&full_path);
let _ = std::fs::remove_file(&full_path);
std::fs::write(&full_path, link_target.as_os_str().as_encoded_bytes())
.map_err(|e| extraction_err(tar_path, e))?;
set_host_permissions(&full_path, 0o600)?;
set_override_stat(&full_path, uid, gid, mode, 0)?;
clear_implicit_entry(&full_path, dest, &mut implicit_dirs);
} else {
let _ = std::fs::remove_dir_all(&full_path);
let _ = std::fs::remove_file(&full_path);
std::os::unix::fs::symlink(&link_target, &full_path)
.map_err(|e| extraction_err(tar_path, e))?;
set_override_stat_symlink(&full_path, uid, gid, mode, 0)?;
clear_implicit_entry(&full_path, dest, &mut implicit_dirs);
}
} else if entry_type == tar::EntryType::Regular || entry_type == tar::EntryType::Continuous
{
if size > MAX_FILE_SIZE {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("file too large: {} bytes (max {MAX_FILE_SIZE})", size),
source: None,
});
}
total_size += size;
if total_size > MAX_TOTAL_SIZE {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("total extraction size exceeded {MAX_TOTAL_SIZE} bytes"),
source: None,
});
}
ensure_parent_dir(&full_path, dest, &mut implicit_dirs)?;
let mut file = tokio::fs::File::create(&full_path)
.await
.map_err(|e| extraction_err(tar_path, e))?;
tokio::io::copy(&mut entry, &mut file)
.await
.map_err(|e| extraction_err(tar_path, e))?;
drop(file);
set_host_permissions(&full_path, 0o600)?;
let mode = S_IFREG | (tar_mode & 0o7777);
set_override_stat(&full_path, uid, gid, mode, 0)?;
clear_implicit_entry(&full_path, dest, &mut implicit_dirs);
} else if entry_type == tar::EntryType::Block || entry_type == tar::EntryType::Char {
ensure_parent_dir(&full_path, dest, &mut implicit_dirs)?;
std::fs::write(&full_path, b"").map_err(|e| extraction_err(tar_path, e))?;
set_host_permissions(&full_path, 0o600)?;
let major = header.device_major().unwrap_or(None).unwrap_or(0);
let minor = header.device_minor().unwrap_or(None).unwrap_or(0);
let rdev = makedev(major, minor);
let type_bits = if entry_type == tar::EntryType::Block {
S_IFBLK
} else {
S_IFCHR
};
let mode = type_bits | (tar_mode & 0o7777);
set_override_stat(&full_path, uid, gid, mode, rdev)?;
clear_implicit_entry(&full_path, dest, &mut implicit_dirs);
} else if entry_type == tar::EntryType::Fifo {
ensure_parent_dir(&full_path, dest, &mut implicit_dirs)?;
std::fs::write(&full_path, b"").map_err(|e| extraction_err(tar_path, e))?;
set_host_permissions(&full_path, 0o600)?;
let mode = S_IFIFO | (tar_mode & 0o7777);
set_override_stat(&full_path, uid, gid, mode, 0)?;
clear_implicit_entry(&full_path, dest, &mut implicit_dirs);
}
}
for hl in deferred_hardlinks {
if !hl.target.exists() {
tracing::warn!(
target = %hl.target.display(),
link = %hl.path.display(),
"hardlink target not found, skipping"
);
continue;
}
ensure_parent_dir(&hl.path, dest, &mut implicit_dirs)?;
let _ = std::fs::remove_file(&hl.path);
std::fs::hard_link(&hl.target, &hl.path).map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!(
"failed to create hardlink {} -> {}: {e}",
hl.path.display(),
hl.target.display()
),
source: Some(Box::new(e)),
})?;
clear_implicit_entry(&hl.path, dest, &mut implicit_dirs);
}
Ok(ExtractionResult {
implicit_dirs: implicit_dirs.into_iter().collect(),
})
}
fn validate_entry_path(dest: &Path, entry_path: &Path, tar_path: &Path) -> ImageResult<PathBuf> {
if entry_path.is_absolute() {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("absolute path in tar entry: {}", entry_path.display()),
source: None,
});
}
let mut depth = 0usize;
for component in entry_path.components() {
match component {
Component::ParentDir => {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("path traversal in tar entry: {}", entry_path.display()),
source: None,
});
}
Component::Normal(_) => {
depth += 1;
if depth > MAX_PATH_DEPTH {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!(
"path too deep ({depth} components): {}",
entry_path.display()
),
source: None,
});
}
}
_ => {}
}
}
let full_path = dest.join(entry_path);
ensure_host_path_contained(dest, &full_path, tar_path)?;
Ok(full_path)
}
fn ensure_parent_dir(
path: &Path,
dest: &Path,
implicit_dirs: &mut BTreeSet<PathBuf>,
) -> ImageResult<()> {
if let Some(parent) = path.parent() {
if parent.exists() {
return Ok(());
}
let mut missing = Vec::new();
let mut current = parent.to_path_buf();
while !current.exists() && current != *dest {
missing.push(current.clone());
if let Some(p) = current.parent() {
current = p.to_path_buf();
} else {
break;
}
}
for dir in missing.into_iter().rev() {
std::fs::create_dir(&dir).map_err(|e| ImageError::Extraction {
digest: String::new(),
message: format!("failed to create dir {}: {e}", dir.display()),
source: Some(Box::new(e)),
})?;
set_host_permissions(&dir, 0o700)?;
let mode = S_IFDIR | 0o755;
set_override_stat(&dir, 0, 0, mode, 0)?;
if let Ok(rel) = dir.strip_prefix(dest) {
implicit_dirs.insert(rel.to_path_buf());
}
}
}
Ok(())
}
fn clear_implicit_entry(path: &Path, dest: &Path, implicit_dirs: &mut BTreeSet<PathBuf>) {
if let Ok(rel) = path.strip_prefix(dest) {
implicit_dirs.remove(rel);
}
}
fn set_host_permissions(path: &Path, mode: u32) -> ImageResult<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| {
ImageError::Extraction {
digest: String::new(),
message: format!("failed to set permissions on {}: {e}", path.display()),
source: Some(Box::new(e)),
}
})
}
fn override_stat_bytes(uid: u32, gid: u32, mode: u32, rdev: u32) -> [u8; 20] {
let mut buf = [0u8; 20];
buf[0] = OVERRIDE_STAT_VERSION;
buf[4..8].copy_from_slice(&uid.to_le_bytes());
buf[8..12].copy_from_slice(&gid.to_le_bytes());
buf[12..16].copy_from_slice(&mode.to_le_bytes());
buf[16..20].copy_from_slice(&rdev.to_le_bytes());
buf
}
fn set_override_stat(path: &Path, uid: u32, gid: u32, mode: u32, rdev: u32) -> ImageResult<()> {
let bytes = override_stat_bytes(uid, gid, mode, rdev);
xattr::set(path, OVERRIDE_XATTR_KEY, &bytes).map_err(|e| ImageError::Extraction {
digest: String::new(),
message: format!("failed to set xattr on {}: {e}", path.display()),
source: Some(Box::new(e)),
})
}
#[cfg(target_os = "macos")]
fn set_override_stat_symlink(
path: &Path,
uid: u32,
gid: u32,
mode: u32,
rdev: u32,
) -> ImageResult<()> {
let bytes = override_stat_bytes(uid, gid, mode, rdev);
use std::{ffi::CString, os::unix::ffi::OsStrExt};
let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|e| ImageError::Extraction {
digest: String::new(),
message: format!("invalid path for xattr: {e}"),
source: None,
})?;
let c_name = CString::new(OVERRIDE_XATTR_KEY).unwrap();
let ret = unsafe {
libc::setxattr(
c_path.as_ptr(),
c_name.as_ptr(),
bytes.as_ptr() as *const libc::c_void,
bytes.len(),
0, libc::XATTR_NOFOLLOW,
)
};
if ret != 0 {
let e = std::io::Error::last_os_error();
return Err(ImageError::Extraction {
digest: String::new(),
message: format!("failed to set xattr on symlink {}: {e}", path.display()),
source: Some(Box::new(e)),
});
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn set_override_stat_symlink(
_path: &Path,
_uid: u32,
_gid: u32,
_mode: u32,
_rdev: u32,
) -> ImageResult<()> {
Ok(())
}
fn makedev(major: u32, minor: u32) -> u32 {
((major & 0xFFF) << 8) | (minor & 0xFF) | ((minor & 0xFFFFF00) << 12)
}
fn extraction_err(
tar_path: &Path,
e: impl Into<Box<dyn std::error::Error + Send + Sync>>,
) -> ImageError {
let source = e.into();
ImageError::Extraction {
digest: tar_path.display().to_string(),
message: source.to_string(),
source: Some(source),
}
}
pub(crate) fn fixup_implicit_dirs(
layer_dir: &Path,
implicit_dirs: &[PathBuf],
lower_layers: &[PathBuf],
) -> ImageResult<()> {
for rel_dir in implicit_dirs {
let target = layer_dir.join(rel_dir);
if !target.exists() {
continue;
}
for lower in lower_layers.iter().rev() {
let source = lower.join(rel_dir);
if source.exists() {
if let Ok(Some(data)) = xattr::get(&source, OVERRIDE_XATTR_KEY)
&& let Err(e) = xattr::set(&target, OVERRIDE_XATTR_KEY, &data)
{
tracing::warn!(
target = %target.display(),
source = %source.display(),
error = %e,
"failed to copy override_stat xattr during fixup"
);
}
break;
}
}
}
Ok(())
}
fn ensure_host_path_contained(dest: &Path, path: &Path, tar_path: &Path) -> ImageResult<()> {
let root = std::fs::canonicalize(dest).map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!(
"failed to canonicalize extraction root {}: {e}",
dest.display()
),
source: Some(Box::new(e)),
})?;
let mut ancestor = path;
while !ancestor.exists() {
ancestor = ancestor.parent().ok_or_else(|| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("invalid extraction path: {}", path.display()),
source: None,
})?;
}
let canonical_ancestor =
std::fs::canonicalize(ancestor).map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("failed to canonicalize {}: {e}", ancestor.display()),
source: Some(Box::new(e)),
})?;
if !canonical_ancestor.starts_with(&root) {
return Err(ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!(
"tar entry escapes extraction root via symlinked ancestor: {}",
path.display()
),
source: None,
});
}
Ok(())
}
fn detect_layer_compression(
tar_path: &Path,
media_type: Option<&str>,
) -> ImageResult<LayerCompression> {
if let Some(media_type) = media_type {
if media_type.contains("zstd") {
return Ok(LayerCompression::Zstd);
}
if media_type.contains("gzip") {
return Ok(LayerCompression::Gzip);
}
if media_type.contains(".tar") {
return Ok(LayerCompression::Plain);
}
}
let mut file = std::fs::File::open(tar_path).map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("failed to open tarball for compression detection: {e}"),
source: Some(Box::new(e)),
})?;
let mut header = [0u8; 4];
let read = file.read(&mut header).map_err(|e| ImageError::Extraction {
digest: tar_path.display().to_string(),
message: format!("failed to read tarball header: {e}"),
source: Some(Box::new(e)),
})?;
if read >= 2 && header[..2] == [0x1F, 0x8B] {
return Ok(LayerCompression::Gzip);
}
if read >= 4 && header == [0x28, 0xB5, 0x2F, 0xFD] {
return Ok(LayerCompression::Zstd);
}
Ok(LayerCompression::Plain)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use tempfile::tempdir;
use super::{
LayerCompression, OVERRIDE_XATTR_KEY, detect_layer_compression, extract_layer,
validate_entry_path,
};
#[test]
fn test_detect_layer_compression_from_media_type() {
assert_eq!(
detect_layer_compression(
Path::new("/nonexistent"),
Some("application/vnd.oci.image.layer.v1.tar+gzip")
)
.unwrap(),
LayerCompression::Gzip,
);
assert_eq!(
detect_layer_compression(
Path::new("/nonexistent"),
Some("application/vnd.oci.image.layer.v1.tar+zstd")
)
.unwrap(),
LayerCompression::Zstd,
);
assert_eq!(
detect_layer_compression(
Path::new("/nonexistent"),
Some("application/vnd.oci.image.layer.v1.tar")
)
.unwrap(),
LayerCompression::Plain,
);
}
#[test]
fn test_validate_entry_path_rejects_symlink_escape() {
let temp = tempdir().unwrap();
let root = temp.path().join("root");
let outside = temp.path().join("outside");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let err = validate_entry_path(&root, Path::new("escape/file.txt"), Path::new("layer.tar"))
.unwrap_err();
assert!(err.to_string().contains("escapes extraction root"));
}
#[test]
fn test_extract_layer_clears_explicit_dirs_from_fixup_set() {
let temp = tempdir().unwrap();
let tar_path = temp.path().join("layer.tar");
let dest = temp.path().join("dest");
std::fs::create_dir(&dest).unwrap();
let tar_file = std::fs::File::create(&tar_path).unwrap();
let mut builder = tar::Builder::new(tar_file);
let file_contents = b"tool";
let mut file_header = tar::Header::new_gnu();
file_header.set_size(file_contents.len() as u64);
file_header.set_mode(0o644);
file_header.set_uid(0);
file_header.set_gid(0);
file_header.set_cksum();
builder
.append_data(&mut file_header, "usr/local/bin/tool", &file_contents[..])
.unwrap();
let mut dir_header = tar::Header::new_gnu();
dir_header.set_entry_type(tar::EntryType::Directory);
dir_header.set_size(0);
dir_header.set_mode(0o700);
dir_header.set_uid(42);
dir_header.set_gid(7);
dir_header.set_cksum();
builder
.append_data(&mut dir_header, "usr/local", std::io::empty())
.unwrap();
builder.finish().unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let result = runtime
.block_on(extract_layer(
&tar_path,
&dest,
Some("application/vnd.oci.image.layer.v1.tar"),
None,
0,
))
.unwrap();
assert!(
!result
.implicit_dirs
.contains(&Path::new("usr/local").to_path_buf())
);
assert!(
result
.implicit_dirs
.contains(&Path::new("usr").to_path_buf())
);
assert!(
result
.implicit_dirs
.contains(&Path::new("usr/local/bin").to_path_buf())
);
let xattr = xattr::get(dest.join("usr/local"), OVERRIDE_XATTR_KEY)
.unwrap()
.unwrap();
assert_eq!(xattr.len(), 20);
assert_eq!(u32::from_le_bytes(xattr[4..8].try_into().unwrap()), 42);
assert_eq!(u32::from_le_bytes(xattr[8..12].try_into().unwrap()), 7);
assert_eq!(
u32::from_le_bytes(xattr[12..16].try_into().unwrap()) & 0o7777,
0o700
);
}
#[test]
fn test_extract_layer_emits_progress_events() {
use crate::progress::{PullProgress, progress_channel};
let temp = tempdir().unwrap();
let tar_path = temp.path().join("layer.tar");
let dest = temp.path().join("dest");
std::fs::create_dir(&dest).unwrap();
let tar_file = std::fs::File::create(&tar_path).unwrap();
let mut builder = tar::Builder::new(tar_file);
let data = vec![0u8; 128 * 1024];
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
builder
.append_data(&mut header, "bigfile.bin", &data[..])
.unwrap();
builder.finish().unwrap();
let (mut handle, sender) = progress_channel();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime
.block_on(extract_layer(
&tar_path,
&dest,
Some("application/vnd.oci.image.layer.v1.tar"),
Some(&sender),
7, ))
.unwrap();
drop(sender);
let mut events = Vec::new();
while let Some(event) = runtime.block_on(handle.recv()) {
events.push(event);
}
let progress_events: Vec<_> = events
.iter()
.filter_map(|e| match e {
PullProgress::LayerExtractProgress {
layer_index,
bytes_read,
total_bytes,
} => Some((*layer_index, *bytes_read, *total_bytes)),
_ => None,
})
.collect();
assert!(
!progress_events.is_empty(),
"expected LayerExtractProgress events"
);
for &(idx, _, _) in &progress_events {
assert_eq!(idx, 7);
}
for window in progress_events.windows(2) {
assert!(window[1].1 >= window[0].1);
}
let tar_size = std::fs::metadata(&tar_path).unwrap().len();
for &(_, _, total) in &progress_events {
assert_eq!(total, tar_size);
}
}
}