refactor(extract): report extraction progress through the sink

The extraction spinner drew straight to stderr from inside the
library, so every consumer got terminal UI it never asked for.
extract_tarball and its helpers now count extracted files themselves
and emit Progress events; rendering belongs to the caller.
This commit is contained in:
2026-10-02 23:51:42 +02:00
parent d481bd9028
commit 82d2ed3382
3 changed files with 106 additions and 111 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ fn main() -> Result<()> {
arch: args.arch.clone().unwrap_or_default(),
no_cache: args.no_cache,
};
let rootfs = cache.prepare(&request)?;
let rootfs = cache.prepare(&request, &ecr::NoProgress)?;
// Branch based on --kernel flag
// Option<Option<PathBuf>> (require_equals: the value must use =PATH syntax
+86 -99
View File
@@ -1,13 +1,13 @@
use crate::progress::{Progress, Unit};
use crate::veprintln;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::fs::File;
use std::io::{BufReader, Read};
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
/// Extract a tarball to the specified directory
pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
pub fn extract_tarball(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> {
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
// OCI bundle files are always named "oci-…" by generate_cache_filename.
@@ -16,15 +16,15 @@ pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
// reading the entire compressed archive twice for large OCI images).
if filename.starts_with("oci-") {
veprintln!("Detected multi-layer OCI image, extracting layers...");
return extract_multi_layer_oci(tarball, dest);
return extract_multi_layer_oci(tarball, dest, progress);
}
extract_single_archive(tarball, dest)
extract_single_archive(tarball, dest, progress)
}
/// Extract a plain rootfs tarball, detecting the compression format from
/// the filename with a magic-byte fallback.
fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
fn extract_single_archive(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> {
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
let file = File::open(tarball)
@@ -35,13 +35,13 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
// Detect compression format from filename
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
extract_gz(reader, dest, &mut fixups)?;
extract_gz(reader, dest, &mut fixups, progress)?;
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
extract_xz(reader, dest, &mut fixups)?;
extract_xz(reader, dest, &mut fixups, progress)?;
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
extract_zst(reader, dest, &mut fixups)?;
extract_zst(reader, dest, &mut fixups, progress)?;
} else if filename.ends_with(".tar") {
extract_tar(reader, dest, &mut fixups)?;
extract_tar(reader, dest, &mut fixups, progress)?;
} else {
// Try to detect from magic bytes
let mut magic = [0u8; 6];
@@ -53,25 +53,25 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
// gzip magic
drop(peek_reader);
let file = File::open(tarball)?;
extract_gz(BufReader::new(file), dest, &mut fixups)?;
extract_gz(BufReader::new(file), dest, &mut fixups, progress)?;
}
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => {
// xz magic
drop(peek_reader);
let file = File::open(tarball)?;
extract_xz(BufReader::new(file), dest, &mut fixups)?;
extract_xz(BufReader::new(file), dest, &mut fixups, progress)?;
}
[0x28, 0xb5, 0x2f, 0xfd, ..] => {
// zstd magic
drop(peek_reader);
let file = File::open(tarball)?;
extract_zst(BufReader::new(file), dest, &mut fixups)?;
extract_zst(BufReader::new(file), dest, &mut fixups, progress)?;
}
_ => {
// Assume uncompressed tar
drop(peek_reader);
let file = File::open(tarball)?;
extract_tar(BufReader::new(file), dest, &mut fixups)?;
extract_tar(BufReader::new(file), dest, &mut fixups, progress)?;
}
}
}
@@ -80,7 +80,7 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
}
/// Extract a multi-layer OCI image
fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
fn extract_multi_layer_oci(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> {
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
// Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files)
@@ -116,7 +116,7 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
// an OCI cache entry (the dispatch above keys on the "oci-" filename
// prefix). The outer archive is a plain rootfs tarball.
veprintln!("No layers.manifest in bundle; extracting as plain rootfs tarball");
return extract_single_archive(tarball, dest);
return extract_single_archive(tarball, dest, progress);
}
// Read the layers manifest
@@ -135,7 +135,7 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
continue;
}
veprintln!("Extracting layer: {}", layer_name);
extract_oci_layer(&layer_path, dest, &mut fixups)?;
extract_oci_layer(&layer_path, dest, &mut fixups, progress)?;
}
apply_fixups(dest, &mut fixups)
@@ -144,7 +144,12 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
/// Decompress and extract one OCI layer tarball into `dest`, honouring
/// whiteout markers. Compression is inferred from the filename then from
/// magic bytes.
fn extract_oci_layer(layer_path: &Path, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
fn extract_oci_layer(
layer_path: &Path,
dest: &Path,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
use std::io::Read;
let layer_name = layer_path
.file_name()
@@ -154,82 +159,63 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path, fixups: &mut DeferredFixups
.with_context(|| format!("Failed to open layer: {}", layer_path.display()))?;
let reader = BufReader::new(file);
if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") {
extract_with_progress(
tar::Archive::new(flate2::read::GzDecoder::new(reader)),
dest,
"Extracting OCI layer",
fixups,
)
} else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") {
extract_with_progress(
tar::Archive::new(xz2::read::XzDecoder::new(reader)),
dest,
"Extracting OCI layer",
fixups,
)
} else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") {
extract_with_progress(
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?),
dest,
"Extracting OCI layer",
fixups,
)
} else {
// Fall back to magic-byte detection
let mut magic = [0u8; 6];
let mut peek = BufReader::new(File::open(layer_path)?);
let _ = peek.read_exact(&mut magic); // short reads are fine for detection
drop(peek);
match magic {
[0x1f, 0x8b, ..] => extract_with_progress(
tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open(
layer_path,
)?))),
dest,
"Extracting OCI layer",
fixups,
),
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_with_progress(
tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open(
layer_path,
)?))),
dest,
"Extracting OCI layer",
fixups,
),
[0x28, 0xb5, 0x2f, 0xfd, ..] => extract_with_progress(
tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new(
File::open(layer_path)?,
))?),
dest,
"Extracting OCI layer",
fixups,
),
_ => extract_with_progress(
tar::Archive::new(BufReader::new(File::open(layer_path)?)),
dest,
"Extracting OCI layer",
fixups,
),
}
}
// The decompressor type differs per format; erase it behind a trait
// object so a single archive-extraction call site serves every branch.
let archive: tar::Archive<Box<dyn Read>> =
if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") {
tar::Archive::new(Box::new(flate2::read::GzDecoder::new(reader)))
} else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") {
tar::Archive::new(Box::new(xz2::read::XzDecoder::new(reader)))
} else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") {
tar::Archive::new(Box::new(zstd::stream::read::Decoder::new(reader)?))
} else {
// Fall back to magic-byte detection
let mut magic = [0u8; 6];
let mut peek = BufReader::new(File::open(layer_path)?);
let _ = peek.read_exact(&mut magic); // short reads are fine for detection
drop(peek);
let file = File::open(layer_path)?;
match magic {
[0x1f, 0x8b, ..] => {
tar::Archive::new(Box::new(flate2::read::GzDecoder::new(BufReader::new(file))))
}
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => {
tar::Archive::new(Box::new(xz2::read::XzDecoder::new(BufReader::new(file))))
}
[0x28, 0xb5, 0x2f, 0xfd, ..] => tar::Archive::new(Box::new(
zstd::stream::read::Decoder::new(BufReader::new(file))?,
)),
_ => tar::Archive::new(Box::new(BufReader::new(file))),
}
};
extract_archive(archive, dest, "Extracting OCI layer", fixups, progress)
}
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
fn extract_gz<R: std::io::Read>(
reader: R,
dest: &Path,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
let gz_decoder = flate2::read::GzDecoder::new(reader);
let archive = tar::Archive::new(gz_decoder);
extract_with_progress(archive, dest, "Extracting gzip archive", fixups)?;
extract_archive(archive, dest, "Extracting gzip archive", fixups, progress)?;
Ok(())
}
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> {
fn extract_xz<R: std::io::Read>(
reader: R,
dest: &Path,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
let xz_decoder = xz2::read::XzDecoder::new(reader);
let archive = tar::Archive::new(xz_decoder);
extract_with_progress(archive, dest, "Extracting xz archive", fixups)?;
extract_archive(archive, dest, "Extracting xz archive", fixups, progress)?;
Ok(())
}
@@ -238,11 +224,12 @@ fn extract_zst<R: std::io::Read>(
reader: R,
dest: &Path,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
let zst_decoder = zstd::Decoder::new(reader)?;
let archive = tar::Archive::new(zst_decoder);
extract_with_progress(archive, dest, "Extracting zstd archive", fixups)?;
extract_archive(archive, dest, "Extracting zstd archive", fixups, progress)?;
Ok(())
}
@@ -251,10 +238,11 @@ fn extract_tar<R: std::io::Read>(
reader: R,
dest: &Path,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
let archive = tar::Archive::new(reader);
extract_with_progress(archive, dest, "Extracting tar archive", fixups)?;
extract_archive(archive, dest, "Extracting tar archive", fixups, progress)?;
Ok(())
}
@@ -344,20 +332,16 @@ fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> Result<()> {
Ok(())
}
/// Extract tar archive with progress bar, handling whiteout files for OCI layers
fn extract_with_progress<R: std::io::Read>(
/// Extract tar archive entries, reporting a per-file count through
/// `progress` and handling whiteout files for OCI layers
fn extract_archive<R: std::io::Read>(
mut archive: tar::Archive<R>,
dest: &Path,
msg: &str,
label: &str,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg} ({pos} files)")
.unwrap(),
);
pb.set_message(msg.to_string());
progress.begin(label, Unit::Files, None);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
@@ -367,6 +351,7 @@ fn extract_with_progress<R: std::io::Read>(
.entries()
.context("Failed to read archive entries")?;
let mut count: u64 = 0;
for entry in entries {
let mut entry = entry.context("Failed to read archive entry")?;
@@ -431,11 +416,12 @@ fn extract_with_progress<R: std::io::Read>(
.with_context(|| format!("Failed to extract {}", path.display()))?;
}
pb.inc(1);
count += 1;
progress.advance(1);
}
pb.finish_and_clear();
veprintln!("Extracted {} files", pb.position());
progress.finish();
veprintln!("Extracted {} files", count);
Ok(())
}
@@ -455,6 +441,7 @@ fn remove_path(path: &Path) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::progress::NoProgress;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use tar::{Builder, EntryType, Header};
@@ -533,7 +520,7 @@ mod tests {
],
);
extract_tarball(&tarball, out.path()).unwrap();
extract_tarball(&tarball, out.path(), &NoProgress).unwrap();
let base = out
.path()
@@ -575,7 +562,7 @@ mod tests {
],
);
extract_tarball(&tarball, out.path()).unwrap();
extract_tarball(&tarball, out.path(), &NoProgress).unwrap();
assert_eq!(
std::fs::read(out.path().join("etc/dir/link.0")).unwrap(),
b"payload"
@@ -618,7 +605,7 @@ mod tests {
],
);
extract_tarball(&tarball, out.path()).unwrap();
extract_tarball(&tarball, out.path(), &NoProgress).unwrap();
assert_eq!(
std::fs::read(out.path().join("etc/dir/keep.txt")).unwrap(),
b"kept",
+19 -11
View File
@@ -8,13 +8,13 @@
//! entry and skips both the download and the provisioning step:
//!
//! ```no_run
//! use ecr::{chroot, ExecOptions, PrepareRequest, RootfsCache};
//! use ecr::{chroot, ExecOptions, NoProgress, PrepareRequest, RootfsCache};
//!
//! # fn main() -> anyhow::Result<()> {
//! let cache = RootfsCache::new(RootfsCache::default_dir()?);
//! let req = PrepareRequest::new("alpine:3.23");
//!
//! let rootfs = cache.prepare_provisioned(&req, |rootfs| {
//! let rootfs = cache.prepare_provisioned(&req, &NoProgress, |rootfs| {
//! let code = rootfs.exec(&ExecOptions {
//! command: vec!["apk".into(), "add".into(), "curl".into()],
//! env: chroot::default_env(rootfs.dir()),
@@ -47,6 +47,7 @@ use crate::distro::{
use crate::download::{digest_sidecar, download_image, fetch_oci_digest};
use crate::exec::ExecOptions;
use crate::extract::extract_tarball;
use crate::progress::Progress;
use crate::veprintln;
/// Sidecar suffix marking a cache entry that holds a provisioned rootfs.
@@ -89,8 +90,9 @@ impl RootfsCache {
/// Resolve `req`, download through the cache when needed, and extract
/// into a fresh scratch directory (removed when the returned
/// [`PreparedRootfs`] is dropped).
pub fn prepare(&self, req: &PrepareRequest) -> Result<PreparedRootfs> {
/// [`PreparedRootfs`] is dropped). Download and extraction progress is
/// reported through `progress`.
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
let arch = req.resolved_arch();
let source = resolve_source(&req.image, &arch)?;
let cache_path = self.tarball_path(&source, &arch);
@@ -143,7 +145,7 @@ impl RootfsCache {
veprintln!("Using cached tarball: {}", cache_path.display());
}
extract_prepared(cache_path, arch)
extract_prepared(cache_path, arch, progress)
}
/// Prepare a provisioned rootfs with cache amortization: when the cache
@@ -161,6 +163,7 @@ impl RootfsCache {
pub fn prepare_provisioned<F>(
&self,
req: &PrepareRequest,
progress: &dyn Progress,
provision: F,
) -> Result<PreparedRootfs>
where
@@ -173,10 +176,10 @@ impl RootfsCache {
if !req.no_cache && cache_path.exists() && marker.exists() {
veprintln!("Using provisioned cache entry: {}", cache_path.display());
return extract_prepared(cache_path, arch);
return extract_prepared(cache_path, arch, progress);
}
let rootfs = self.prepare(req)?;
let rootfs = self.prepare(req, progress)?;
provision(&rootfs)?;
rootfs.persist().with_context(|| {
format!(
@@ -298,12 +301,16 @@ fn resolve_source(image: &str, arch: &str) -> Result<ImageSource> {
}
}
fn extract_prepared(cache_path: PathBuf, arch: String) -> Result<PreparedRootfs> {
fn extract_prepared(
cache_path: PathBuf,
arch: String,
progress: &dyn Progress,
) -> Result<PreparedRootfs> {
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
let rootfs = temp.path().to_path_buf();
veprintln!("Extracting to: {}", rootfs.display());
extract_tarball(&cache_path, &rootfs)?;
extract_tarball(&cache_path, &rootfs, progress)?;
Ok(PreparedRootfs {
rootfs: temp,
@@ -461,6 +468,7 @@ fn get_tarball_extension(url: &str) -> &str {
#[cfg(test)]
mod tests {
use super::*;
use crate::progress::NoProgress;
use std::os::unix::fs::PermissionsExt;
fn direct_tarball(distro: Distro, version: &str) -> ImageSource {
@@ -539,7 +547,7 @@ mod tests {
pack_rootfs(src.path(), &dest).unwrap();
let out = tempfile::tempdir().unwrap();
crate::extract::extract_tarball(&dest, out.path()).unwrap();
crate::extract::extract_tarball(&dest, out.path(), &NoProgress).unwrap();
assert_eq!(
std::fs::read(out.path().join("hello.txt")).unwrap(),
@@ -596,7 +604,7 @@ mod tests {
// The persisted entry extracts back to the original contents
let out = tempfile::tempdir().unwrap();
crate::extract::extract_tarball(&cache_path, out.path()).unwrap();
crate::extract::extract_tarball(&cache_path, out.path(), &NoProgress).unwrap();
assert_eq!(std::fs::read(out.path().join("file.txt")).unwrap(), b"data");
}