use std::collections::HashSet;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Component, Path, PathBuf};
use std::sync::{mpsc, Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, SystemTime};
use crate::decode::{open_layer, Decoder};
use crate::error::Context;
use crate::{Error, Result};
pub const SMALL_FILE: u64 = 1 << 20;
pub const IN_FLIGHT: usize = 16 << 20;
const CHUNK: usize = 1 << 20;
#[derive(Debug, Clone)]
pub struct UnpackOptions {
pub threads: usize,
pub parallel_gzip: bool,
pub same_owner: bool,
pub layout: Layout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Layout {
#[default]
Flatten,
Overlay(OverlayXattr),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverlayXattr {
Trusted,
User,
}
impl Default for UnpackOptions {
fn default() -> Self {
Self {
threads: 0,
parallel_gzip: true,
same_owner: fs::metadata("/proc/self").is_ok_and(|m| m.uid() == 0),
layout: Layout::Flatten,
}
}
}
impl UnpackOptions {
pub fn threads(&self) -> usize {
if self.threads > 0 {
self.threads
} else {
thread::available_parallelism().map_or(1, |n| n.get())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackStats {
pub decoder: Decoder,
pub entries: u64,
pub files: u64,
pub bytes: u64,
pub whiteouts: u64,
pub skipped_devices: u64,
}
pub fn apply_layer_file(blob: &Path, rootfs: &Path, opts: &UnpackOptions) -> Result<UnpackStats> {
let (reader, decoder) = open_layer(blob, opts)?;
let mut stats = apply_layer(reader, rootfs, opts)?;
stats.decoder = decoder;
Ok(stats)
}
pub(crate) fn apply_layer_file_digest(
blob: &Path,
rootfs: &Path,
opts: &UnpackOptions,
) -> Result<(UnpackStats, crate::Digest)> {
let (reader, decoder) = open_layer(blob, opts)?;
let hasher = Arc::new(Mutex::new(crate::Hasher::default()));
let reader = HashReader {
inner: reader,
hasher: hasher.clone(),
};
let mut stats = apply_layer(reader, rootfs, opts)?;
stats.decoder = decoder;
let digest = std::mem::take(&mut *hasher.lock().unwrap()).finish();
Ok((stats, digest))
}
struct HashReader<R> {
inner: R,
hasher: Arc<Mutex<crate::Hasher>>,
}
impl<R: Read> Read for HashReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
self.hasher.lock().unwrap().update(&buf[..n]);
Ok(n)
}
}
pub fn apply_layer<R: Read + Send>(
tar: R,
rootfs: &Path,
opts: &UnpackOptions,
) -> Result<UnpackStats> {
let threads = opts.threads();
let root = rootfs
.canonicalize()
.ctx(|| format!("rootfs {}", rootfs.display()))?;
let mut stats = UnpackStats {
decoder: Decoder::Plain,
entries: 0,
files: 0,
bytes: 0,
whiteouts: 0,
skipped_devices: 0,
};
let mut dirs: Vec<(PathBuf, u64)> = Vec::new();
thread::scope(|s| -> Result<()> {
let reader: Box<dyn Read> = if threads > 1 {
Box::new(read_ahead(s, tar))
} else {
Box::new(tar)
};
let mut pool = Pool::new(s, threads.saturating_sub(1), opts.same_owner);
let mut archive = tar::Archive::new(reader);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(opts.same_owner);
archive.set_preserve_mtime(true);
archive.set_overwrite(true);
archive.set_unpack_xattrs(false);
let mut ours: HashSet<PathBuf> = HashSet::new();
let mut checked: HashSet<PathBuf> = HashSet::new();
for entry in archive.entries().ctx(|| "reading layer".into())? {
let mut entry = entry.ctx(|| "reading layer".into())?;
stats.entries += 1;
let raw = entry.path().ctx(|| "entry path".into())?.into_owned();
let Some(rel) = entry_path(&raw) else {
continue;
};
let name = rel.file_name().and_then(|n| n.to_str()).unwrap_or("");
let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
if let Layout::Overlay(xattr) = opts.layout {
let marker = if name == ".wh..wh..opq" {
Some(None)
} else {
name.strip_prefix(".wh.").map(Some)
};
if let Some(marker) = marker {
stats.whiteouts += 1;
check_parent(&root, &parent, &mut checked)?;
let at = match marker {
None => {
set_opaque(&root.join(&parent), xattr)
.ctx(|| format!("marking {} opaque", parent.display()))?;
parent
}
Some(hidden) => {
let hidden = parent.join(hidden);
if pool.inflight.contains(&hidden) {
pool.drain()?;
}
remove_any(&root.join(&hidden));
whiteout(&root.join(&hidden))
.ctx(|| format!("whiteout {}", hidden.display()))?;
hidden
}
};
add_with_parents(&mut ours, &at);
continue;
}
}
if name == ".wh..wh..opq" {
stats.whiteouts += 1;
checked.clear();
if let Ok(children) = fs::read_dir(root.join(&parent)) {
for child in children.flatten() {
hide(&root, &parent.join(child.file_name()), &ours);
}
}
continue;
}
if let Some(hidden) = name.strip_prefix(".wh.") {
stats.whiteouts += 1;
checked.clear();
hide(&root, &parent.join(hidden), &ours);
continue;
}
let kind = entry.header().entry_type();
let target = root.join(&rel);
if pool.inflight.contains(&rel) {
pool.drain()?;
}
if kind.is_hard_link() {
if let Some(src) = entry
.link_name()
.ok()
.flatten()
.and_then(|l| entry_path(&l))
{
if pool.inflight.contains(&src) {
pool.drain()?;
}
}
}
if let Ok(meta) = fs::symlink_metadata(&target) {
if !(meta.is_dir() && kind.is_dir()) {
if meta.is_dir() && ours.contains(&rel) {
pool.drain()?;
}
checked.clear();
remove_any(&target);
}
}
if kind.is_file() || kind == tar::EntryType::Continuous {
check_parent(&root, &parent, &mut checked)?;
let h = entry.header();
let meta = FileMeta {
mode: h.mode().unwrap_or(0o644) & 0o7777,
uid: h.uid().unwrap_or(0),
gid: h.gid().unwrap_or(0),
mtime: h.mtime().unwrap_or(0),
};
let size = entry.size();
if pool.active() && size <= SMALL_FILE {
let mut data = Vec::with_capacity(size as usize);
entry
.read_to_end(&mut data)
.ctx(|| format!("reading {}", rel.display()))?;
pool.submit(Job { target, data, meta })?;
pool.inflight.insert(rel.clone());
} else {
write_file(&target, &mut entry, &meta, opts.same_owner)
.ctx(|| format!("writing {}", rel.display()))?;
}
stats.files += 1;
stats.bytes += size;
} else {
if kind.is_dir() {
check_parent(&root, &parent, &mut checked)?;
}
if let Err(e) = entry.unpack_in(&root) {
if kind.is_character_special() || kind.is_block_special() {
stats.skipped_devices += 1;
continue;
}
if e.to_string().contains("outside of") {
return Err(Error::Escape(rel));
}
return Err(e).ctx(|| format!("unpacking {}", rel.display()));
}
if kind.is_dir() {
dirs.push((target, entry.header().mtime().unwrap_or(0)));
}
}
add_with_parents(&mut ours, &rel);
}
pool.finish()?;
let mut rest = archive.into_inner();
io::copy(&mut rest, &mut io::sink()).ctx(|| "reading the layer to its end".into())?;
Ok(())
})?;
for (dir, mtime) in dirs.iter().rev() {
if let Ok(f) = File::open(dir) {
let _ = f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime));
}
}
Ok(stats)
}
pub enum Change<'a, 'b, R: Read> {
Opaque(PathBuf),
Whiteout(PathBuf),
Entry(PathBuf, &'a mut tar::Entry<'b, R>),
}
pub fn changes<R: Read>(
tar: R,
mut f: impl FnMut(Change<'_, '_, R>) -> io::Result<()>,
) -> Result<()> {
let mut archive = tar::Archive::new(tar);
for entry in archive.entries().ctx(|| "reading layer".into())? {
let mut entry = entry.ctx(|| "reading layer".into())?;
let raw = entry.path().ctx(|| "entry path".into())?.into_owned();
let Some(rel) = entry_path(&raw) else {
continue;
};
let name = rel.file_name().and_then(|n| n.to_str()).unwrap_or("");
let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
let change = if name == ".wh..wh..opq" {
Change::Opaque(parent)
} else if let Some(hidden) = name.strip_prefix(".wh.") {
Change::Whiteout(parent.join(hidden))
} else {
Change::Entry(rel, &mut entry)
};
f(change).ctx(|| "layer visitor".into())?;
}
Ok(())
}
pub fn entry_path(path: &Path) -> Option<PathBuf> {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::Normal(p) => out.push(p),
Component::CurDir | Component::RootDir => {}
Component::ParentDir | Component::Prefix(_) => return None,
}
}
(!out.as_os_str().is_empty()).then_some(out)
}
fn add_with_parents(set: &mut HashSet<PathBuf>, rel: &Path) {
let mut p = Some(rel);
while let Some(q) = p.filter(|q| !q.as_os_str().is_empty()) {
if !set.insert(q.to_path_buf()) {
break;
}
p = q.parent();
}
}
fn hide(root: &Path, rel: &Path, ours: &HashSet<PathBuf>) {
let full = root.join(rel);
if !ours.contains(rel) {
remove_any(&full);
return;
}
if fs::symlink_metadata(&full).is_ok_and(|m| m.is_dir()) {
if let Ok(children) = fs::read_dir(&full) {
for c in children.flatten() {
hide(root, &rel.join(c.file_name()), ours);
}
}
}
}
fn cpath(p: &Path) -> io::Result<std::ffi::CString> {
std::ffi::CString::new(p.as_os_str().as_encoded_bytes())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
}
fn whiteout(path: &Path) -> io::Result<()> {
let c = cpath(path)?;
if unsafe { libc::mknod(c.as_ptr(), libc::S_IFCHR, 0) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn set_opaque(dir: &Path, xattr: OverlayXattr) -> io::Result<()> {
let c = cpath(dir)?;
let name: &[u8] = match xattr {
OverlayXattr::Trusted => b"trusted.overlay.opaque\0",
OverlayXattr::User => b"user.overlay.opaque\0",
};
let r =
unsafe { libc::lsetxattr(c.as_ptr(), name.as_ptr().cast(), b"y".as_ptr().cast(), 1, 0) };
if r != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn remove_any(path: &Path) {
match fs::symlink_metadata(path) {
Ok(m) if m.is_dir() => {
let _ = fs::remove_dir_all(path);
}
Ok(_) => {
let _ = fs::remove_file(path);
}
Err(_) => {}
}
}
fn check_parent(root: &Path, parent: &Path, checked: &mut HashSet<PathBuf>) -> Result<()> {
if parent.as_os_str().is_empty() || checked.contains(parent) {
return Ok(());
}
let full = root.join(parent);
let mut existing = full.as_path();
while fs::symlink_metadata(existing).is_err() {
existing = existing.parent().unwrap_or(root);
}
let inside = |p: &Path| p.canonicalize().is_ok_and(|c| c.starts_with(root));
if !inside(existing) {
return Err(Error::Escape(parent.to_path_buf()));
}
if existing != full {
fs::create_dir_all(&full).ctx(|| format!("creating {}", parent.display()))?;
if !inside(&full) {
return Err(Error::Escape(parent.to_path_buf()));
}
}
checked.insert(parent.to_path_buf());
Ok(())
}
struct FileMeta {
mode: u32,
uid: u64,
gid: u64,
mtime: u64,
}
fn write_file(
target: &Path,
data: &mut dyn Read,
meta: &FileMeta,
same_owner: bool,
) -> io::Result<()> {
let open = || OpenOptions::new().write(true).create_new(true).open(target);
let mut f = match open() {
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
fs::remove_file(target)?;
open()?
}
r => r?,
};
io::copy(data, &mut f)?;
if same_owner {
std::os::unix::fs::fchown(&f, Some(meta.uid as u32), Some(meta.gid as u32))?;
}
f.set_permissions(fs::Permissions::from_mode(meta.mode))?;
f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(meta.mtime))?;
Ok(())
}
struct Job {
target: PathBuf,
data: Vec<u8>,
meta: FileMeta,
}
#[derive(Default)]
struct State {
pending: usize,
bytes: usize,
error: Option<Error>,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
cv: Condvar,
}
struct Pool {
tx: Option<mpsc::Sender<Job>>,
shared: Arc<Shared>,
inflight: HashSet<PathBuf>,
}
impl Pool {
fn new<'s>(s: &'s thread::Scope<'s, '_>, workers: usize, same_owner: bool) -> Self {
let shared = Arc::new(Shared::default());
if workers == 0 {
return Self {
tx: None,
shared,
inflight: HashSet::new(),
};
}
let (tx, rx) = mpsc::channel::<Job>();
let rx = Arc::new(Mutex::new(rx));
for _ in 0..workers {
let (rx, shared) = (rx.clone(), shared.clone());
s.spawn(move || loop {
let job = rx.lock().unwrap().recv();
let Ok(job) = job else { return };
let len = job.data.len();
let r = write_file(&job.target, &mut &job.data[..], &job.meta, same_owner)
.ctx(|| format!("writing {}", job.target.display()));
let mut st = shared.state.lock().unwrap();
st.pending -= 1;
st.bytes -= len;
if let Err(e) = r {
st.error.get_or_insert(e);
}
shared.cv.notify_all();
});
}
Self {
tx: Some(tx),
shared,
inflight: HashSet::new(),
}
}
fn active(&self) -> bool {
self.tx.is_some()
}
fn submit(&mut self, job: Job) -> Result<()> {
let len = job.data.len();
let mut st = self.shared.state.lock().unwrap();
while st.pending > 0 && st.bytes + len > IN_FLIGHT && st.error.is_none() {
st = self.shared.cv.wait(st).unwrap();
}
if let Some(e) = st.error.take() {
return Err(e);
}
st.pending += 1;
st.bytes += len;
drop(st);
self.tx
.as_ref()
.unwrap()
.send(job)
.expect("writer threads live");
Ok(())
}
fn drain(&mut self) -> Result<()> {
let mut st = self.shared.state.lock().unwrap();
while st.pending > 0 {
st = self.shared.cv.wait(st).unwrap();
}
self.inflight.clear();
st.error.take().map_or(Ok(()), Err)
}
fn finish(mut self) -> Result<()> {
let r = self.drain();
self.tx = None;
r
}
}
fn read_ahead<'s, R: Read + Send + 's>(
s: &'s thread::Scope<'s, '_>,
mut inner: R,
) -> ChannelReader {
let (tx, rx) = mpsc::sync_channel::<io::Result<Vec<u8>>>(4);
s.spawn(move || loop {
let mut buf = vec![0u8; CHUNK];
let mut n = 0;
while n < CHUNK {
match inner.read(&mut buf[n..]) {
Ok(0) => break,
Ok(k) => n += k,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => {
let _ = tx.send(Err(e));
return;
}
}
}
buf.truncate(n);
let done = n < CHUNK;
if (n > 0 && tx.send(Ok(buf)).is_err()) || done {
return;
}
});
ChannelReader {
rx,
buf: Vec::new(),
pos: 0,
}
}
struct ChannelReader {
rx: mpsc::Receiver<io::Result<Vec<u8>>>,
buf: Vec<u8>,
pos: usize,
}
impl Read for ChannelReader {
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
while self.pos == self.buf.len() {
match self.rx.recv() {
Ok(chunk) => {
self.buf = chunk?;
self.pos = 0;
}
Err(_) => return Ok(0),
}
}
let n = out.len().min(self.buf.len() - self.pos);
out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
}
}