use std::io;
use std::io::Read;
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
const SYNTHETIC_PAD_CAP_BYTES: u64 = 8 * 1024 * 1024;
const CUMULATIVE_SYNTHETIC_CAP_BYTES: u64 = 1024 * 1024 * 1024;
const DRAIN_CHUNK_BYTES: usize = 8192;
const DISARMED: u64 = u64::MAX;
struct BudgetState {
limit: u64,
consumed: AtomicU64,
allowance: AtomicU64,
total_read: AtomicU64,
cumulative_synthetic: AtomicU64,
}
#[derive(Clone)]
pub struct TarReadBudget(Arc<BudgetState>);
impl TarReadBudget {
fn arm(&self) {
self.0.consumed.store(0, Ordering::Relaxed);
self.0.allowance.store(self.0.limit, Ordering::Relaxed);
}
fn disarm(&self) {
self.0.allowance.store(DISARMED, Ordering::Relaxed);
}
fn total_read(&self) -> u64 {
self.0.total_read.load(Ordering::Relaxed)
}
fn add_cumulative_synthetic(&self, synthetic: u64) {
if synthetic > 0 {
self.0
.cumulative_synthetic
.fetch_add(synthetic, Ordering::Relaxed);
}
}
fn cumulative_synthetic_violation(&self) -> Option<io::Error> {
let total = self.0.cumulative_synthetic.load(Ordering::Relaxed);
(total > CUMULATIVE_SYNTHETIC_CAP_BYTES).then(|| {
io::Error::new(
io::ErrorKind::InvalidData,
TarCumulativeSyntheticBudgetExceeded {
limit: CUMULATIVE_SYNTHETIC_CAP_BYTES,
},
)
})
}
}
#[derive(Debug)]
struct TarReadBudgetExceeded {
limit: u64,
}
impl std::fmt::Display for TarReadBudgetExceeded {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"TAR metadata read budget exceeded: more than {} bytes read while searching for the \
next archive entry (long-name/long-link/PAX headers, or a run of GNU sparse \
extension blocks)",
self.limit
)
}
}
impl std::error::Error for TarReadBudgetExceeded {}
#[derive(Debug)]
struct TarCumulativeSyntheticBudgetExceeded {
limit: u64,
}
impl std::fmt::Display for TarCumulativeSyntheticBudgetExceeded {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"TAR cumulative synthetic-byte drain budget exceeded: more than {} bytes of \
synthesized (unbacked) padding drained from unread entries across this archive",
self.limit
)
}
}
impl std::error::Error for TarCumulativeSyntheticBudgetExceeded {}
pub fn budget_violation(e: &io::Error) -> Option<crate::ArchiveError> {
let inner = e.get_ref()?;
if let Some(exceeded) = inner.downcast_ref::<TarReadBudgetExceeded>() {
return Some(crate::ArchiveError::SecurityViolation {
reason: exceeded.to_string(),
});
}
if let Some(exceeded) = inner.downcast_ref::<TarCumulativeSyntheticBudgetExceeded>() {
return Some(crate::ArchiveError::SecurityViolation {
reason: exceeded.to_string(),
});
}
None
}
pub struct BudgetedReader<R> {
inner: R,
state: Arc<BudgetState>,
}
impl<R: Read> Read for BudgetedReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let allowance = self.state.allowance.load(Ordering::Relaxed);
if allowance == DISARMED {
let n = self.inner.read(buf)?;
self.state.total_read.fetch_add(n as u64, Ordering::Relaxed);
return Ok(n);
}
let consumed = self.state.consumed.load(Ordering::Relaxed);
if consumed >= allowance {
let mut probe = [0u8; 1];
return match self.inner.read(&mut probe) {
Ok(0) => Ok(0),
Ok(n) => {
self.state.total_read.fetch_add(n as u64, Ordering::Relaxed);
Err(io::Error::new(
io::ErrorKind::InvalidData,
TarReadBudgetExceeded { limit: allowance },
))
}
Err(e) => Err(e),
};
}
let remaining = allowance - consumed;
let want = usize::try_from(remaining)
.unwrap_or(usize::MAX)
.min(buf.len());
let n = self.inner.read(&mut buf[..want])?;
self.state.consumed.fetch_add(n as u64, Ordering::Relaxed);
self.state.total_read.fetch_add(n as u64, Ordering::Relaxed);
Ok(n)
}
}
#[must_use]
pub fn budgeted_reader<R: Read>(inner: R, limit: u64) -> (BudgetedReader<R>, TarReadBudget) {
let state = Arc::new(BudgetState {
limit,
consumed: AtomicU64::new(0),
allowance: AtomicU64::new(limit),
total_read: AtomicU64::new(0),
cumulative_synthetic: AtomicU64::new(0),
});
let reader = BudgetedReader {
inner,
state: Arc::clone(&state),
};
(reader, TarReadBudget(state))
}
pub struct TarEntryGuard<'a, 's, R: Read> {
entry: tar::Entry<'a, BudgetedReader<R>>,
budget: TarReadBudget,
abandoned: bool,
_borrow: PhantomData<&'s mut ()>,
}
impl<R: Read> TarEntryGuard<'_, '_, R> {
pub fn abandon(&mut self) {
self.abandoned = true;
}
}
impl<'a, R: Read> std::ops::Deref for TarEntryGuard<'a, '_, R> {
type Target = tar::Entry<'a, BudgetedReader<R>>;
fn deref(&self) -> &Self::Target {
&self.entry
}
}
impl<R: Read> std::ops::DerefMut for TarEntryGuard<'_, '_, R> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.entry
}
}
impl<R: Read> Drop for TarEntryGuard<'_, '_, R> {
fn drop(&mut self) {
if self.abandoned {
return;
}
let total_read_at_start = self.budget.total_read();
let mut output_so_far: u64 = 0;
let mut synthetic: u64 = 0;
let mut buf = [0u8; DRAIN_CHUNK_BYTES];
loop {
let n = match self.entry.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
output_so_far += n as u64;
let read_since_start = self.budget.total_read() - total_read_at_start;
synthetic = output_so_far.saturating_sub(read_since_start);
if synthetic > SYNTHETIC_PAD_CAP_BYTES {
break;
}
}
self.budget.add_cumulative_synthetic(synthetic);
}
}
pub struct BudgetedEntries<'a, R: Read> {
entries: tar::Entries<'a, BudgetedReader<R>>,
budget: TarReadBudget,
poisoned: bool,
}
impl<'a, R: Read> BudgetedEntries<'a, R> {
fn new(entries: tar::Entries<'a, BudgetedReader<R>>, budget: TarReadBudget) -> Self {
Self {
entries,
budget,
poisoned: false,
}
}
pub fn next_entry(&mut self) -> Option<io::Result<TarEntryGuard<'a, '_, R>>> {
if self.poisoned {
return None;
}
if let Some(err) = self.budget.cumulative_synthetic_violation() {
self.poisoned = true;
return Some(Err(err));
}
self.budget.arm();
let result = self.entries.next();
self.budget.disarm();
match result {
None => None,
Some(Err(e)) => {
self.poisoned = true;
Some(Err(e))
}
Some(Ok(entry)) => Some(Ok(TarEntryGuard {
entry,
budget: self.budget.clone(),
abandoned: false,
_borrow: PhantomData,
})),
}
}
}
pub fn budgeted_tar_entries<R: Read>(
archive: &mut tar::Archive<BudgetedReader<R>>,
budget: TarReadBudget,
) -> io::Result<BudgetedEntries<'_, R>> {
let entries = archive.entries()?;
Ok(BudgetedEntries::new(entries, budget))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn disarmed_reader_passes_bytes_through_unchanged() {
let (mut reader, budget) = budgeted_reader(Cursor::new(b"hello world".to_vec()), 4);
budget.disarm();
let mut out = Vec::new();
reader.read_to_end(&mut out).unwrap();
assert_eq!(out, b"hello world");
}
#[test]
fn armed_reader_allows_exactly_the_limit() {
let (mut reader, budget) = budgeted_reader(Cursor::new(vec![b'a'; 10]), 10);
budget.arm();
let mut out = Vec::new();
reader.read_to_end(&mut out).unwrap();
assert_eq!(out.len(), 10);
}
#[test]
fn armed_reader_trips_at_limit_plus_one_with_err_not_ok_zero() {
let (mut reader, budget) = budgeted_reader(Cursor::new(vec![b'a'; 11]), 10);
budget.arm();
let mut out = Vec::new();
let err = reader
.read_to_end(&mut out)
.expect_err("must error, not silently truncate");
assert!(
budget_violation(&err).is_some(),
"must be recognizable as a budget violation, got: {err:?}"
);
}
#[test]
fn new_reader_is_armed_from_construction() {
let (mut reader, _budget) = budgeted_reader(Cursor::new(vec![b'a'; 11]), 10);
let mut out = Vec::new();
let err = reader
.read_to_end(&mut out)
.expect_err("a freshly constructed reader must already be armed");
assert!(budget_violation(&err).is_some());
}
#[test]
fn rearming_resets_consumed_bytes() {
let (mut reader, budget) = budgeted_reader(Cursor::new(vec![b'a'; 20]), 10);
budget.arm();
let mut buf = [0u8; 10];
reader.read_exact(&mut buf).unwrap();
budget.disarm();
budget.arm();
let mut out = Vec::new();
reader.read_to_end(&mut out).unwrap();
assert_eq!(
out.len(),
10,
"re-armed window must allow a fresh `limit` bytes"
);
}
fn one_entry_tar(name: &str, content: &[u8]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, name, content).unwrap();
builder.into_inner().unwrap()
}
#[test]
fn budgeted_entries_yields_a_well_formed_archive_normally() {
let data = one_entry_tar("file.txt", b"hello");
let (reader, budget) = budgeted_reader(Cursor::new(data), 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let mut guard = entries
.next_entry()
.expect("one entry")
.expect("no io error");
let mut content = Vec::new();
guard.read_to_end(&mut content).unwrap();
assert_eq!(content, b"hello");
drop(guard);
assert!(entries.next_entry().is_none(), "exactly one entry");
}
#[test]
fn drop_drains_unread_entry_so_the_next_header_is_found() {
let mut data = one_entry_tar("skipped.txt", b"unread content");
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_size(5);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "after.txt", &b"hello"[..])
.unwrap();
let second = builder.into_inner().unwrap();
data.truncate(data.len() - 1024); data.extend_from_slice(&second);
let (reader, budget) = budgeted_reader(Cursor::new(data), 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let guard = entries.next_entry().unwrap().unwrap();
drop(guard);
let mut second_guard = entries
.next_entry()
.expect("second entry must still be reachable")
.expect("no io error");
let mut content = Vec::new();
second_guard.read_to_end(&mut content).unwrap();
assert_eq!(content, b"hello");
}
#[test]
fn abandon_suppresses_the_drain() {
let data = one_entry_tar("file.txt", b"hello");
let (reader, budget) = budgeted_reader(Cursor::new(data), 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let mut guard = entries.next_entry().unwrap().unwrap();
guard.abandon();
drop(guard);
}
#[test]
fn oversized_metadata_record_trips_the_budget() {
let long_name = "x".repeat(2048);
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_size(5);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, long_name.as_str(), &b"hello"[..])
.unwrap();
let data = builder.into_inner().unwrap();
let (reader, budget) = budgeted_reader(Cursor::new(data), 512);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let result = entries.next_entry().expect("an entry attempt");
let err = result
.err()
.expect("the long-name record exceeds the 512-byte budget");
assert!(budget_violation(&err).is_some(), "got: {err:?}");
}
struct CountingReader<R> {
inner: R,
count: Arc<AtomicU64>,
}
impl<R: Read> Read for CountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
self.count.fetch_add(n as u64, Ordering::Relaxed);
Ok(n)
}
}
#[test]
fn drop_fully_drains_a_legitimate_large_entry_regardless_of_size() {
let content_len = usize::try_from(SYNTHETIC_PAD_CAP_BYTES).unwrap() * 3;
let data = one_entry_tar("legit_large.bin", &vec![b'A'; content_len]);
let read_count = Arc::new(AtomicU64::new(0));
let counting = CountingReader {
inner: Cursor::new(data),
count: Arc::clone(&read_count),
};
let (reader, budget) = budgeted_reader(counting, 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let guard = entries.next_entry().unwrap().unwrap();
drop(guard);
let drained = read_count.load(Ordering::Relaxed);
assert!(
drained >= 512 + u64::try_from(content_len).unwrap(),
"drop must fully drain a legitimate large entry, drained only {drained} bytes of a \
{content_len}-byte entry"
);
}
fn octal_field(n: u64, width: usize) -> Option<Vec<u8>> {
let digits = format!("{n:o}").into_bytes();
if digits.len() > width - 1 {
return None;
}
let mut out = vec![b'0'; width - 1 - digits.len()];
out.extend_from_slice(&digits);
out.push(0);
Some(out)
}
fn base256_field(n: u64, width: usize) -> Vec<u8> {
let mut out = vec![0u8; width];
let bytes = n.to_be_bytes();
out[width - bytes.len()..].copy_from_slice(&bytes);
out[0] |= 0x80;
out
}
fn num_field(n: u64, width: usize) -> Vec<u8> {
octal_field(n, width).unwrap_or_else(|| base256_field(n, width))
}
const HDR_BLOCK: usize = 512;
fn gnu_sparse_bomb_entry(name: &[u8], realsize: u64) -> Vec<u8> {
assert!(
name.len() <= 100,
"test helper: name field is 100 bytes, {name:?} does not fit"
);
let gap = realsize - HDR_BLOCK as u64;
let mut h = vec![0u8; HDR_BLOCK];
h[..name.len()].copy_from_slice(name);
h[100..108].copy_from_slice(&num_field(0o644, 8));
h[108..116].copy_from_slice(&num_field(0, 8));
h[116..124].copy_from_slice(&num_field(0, 8));
h[124..136].copy_from_slice(&num_field(HDR_BLOCK as u64, 12));
h[136..148].copy_from_slice(&num_field(0, 12));
h[156] = b'S';
h[257..263].copy_from_slice(b"ustar ");
h[263..265].copy_from_slice(b" \0");
h[386..398].copy_from_slice(&num_field(gap, 12));
h[398..410].copy_from_slice(&num_field(HDR_BLOCK as u64, 12));
h[482] = 0;
h[483..495].copy_from_slice(&num_field(realsize, 12));
h[148..156].copy_from_slice(b" ");
let sum: u32 = h.iter().map(|b| u32::from(*b)).sum();
h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
let mut out = h;
out.extend(std::iter::repeat_n(0u8, HDR_BLOCK)); out
}
fn gnu_sparse_bomb_tar(realsize: u64) -> Vec<u8> {
let mut out = gnu_sparse_bomb_entry(b"sparsebomb.bin", realsize);
out.extend(std::iter::repeat_n(0u8, HDR_BLOCK * 2)); out
}
#[test]
fn drop_drain_stops_quickly_on_gnu_sparse_synthesized_padding() {
let data = gnu_sparse_bomb_tar(1u64 << 50);
let read_count = Arc::new(AtomicU64::new(0));
let counting = CountingReader {
inner: Cursor::new(data),
count: Arc::clone(&read_count),
};
let (reader, budget) = budgeted_reader(counting, 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let guard = entries.next_entry().unwrap().unwrap();
drop(guard);
let drained = read_count.load(Ordering::Relaxed);
assert!(
drained
<= 512 + 512 + SYNTHETIC_PAD_CAP_BYTES + u64::try_from(DRAIN_CHUNK_BYTES).unwrap(),
"drop must stop draining almost immediately on synthesized padding, drained {drained} \
bytes"
);
}
fn drain_all_entries<R: Read>(
entries: &mut BudgetedEntries<'_, R>,
) -> (usize, Option<io::Error>) {
let mut yielded = 0usize;
while let Some(result) = entries.next_entry() {
match result {
Ok(guard) => {
yielded += 1;
drop(guard);
}
Err(e) => return (yielded, Some(e)),
}
}
(yielded, None)
}
#[test]
fn cumulative_synthetic_budget_trips_on_many_maximally_saturating_entries() {
const WORST_CASE_BOUND: std::time::Duration = std::time::Duration::from_secs(5);
let entries_needed = CUMULATIVE_SYNTHETIC_CAP_BYTES / SYNTHETIC_PAD_CAP_BYTES + 1;
let entry_count = usize::try_from(entries_needed).unwrap() + 5;
let mut data = Vec::new();
for i in 0..entry_count {
data.extend(gnu_sparse_bomb_entry(
format!("spam{i}.bin").as_bytes(),
1u64 << 40,
));
}
data.extend(std::iter::repeat_n(0u8, HDR_BLOCK * 2));
let (reader, budget) = budgeted_reader(Cursor::new(data), 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let start = std::time::Instant::now();
let (yielded, violation) = drain_all_entries(&mut entries);
let elapsed = start.elapsed();
let err = violation.expect(
"the cumulative synthetic budget must trip before every entry is drained, not \
silently allow all of them",
);
assert!(
budget_violation(&err).is_some(),
"must be recognizable as a budget violation, got: {err:?}"
);
assert!(
yielded < entry_count,
"must fail fast well before draining all {entry_count} entries, but yielded \
{yielded} of them"
);
assert!(
elapsed < WORST_CASE_BOUND,
"the worst case (every entry maximally saturating) took {elapsed:?} to trip, \
expected well under {WORST_CASE_BOUND:?}"
);
}
#[test]
fn cumulative_synthetic_budget_trips_from_many_sub_cap_contributions() {
const GAP: u64 = 4 * 1024 * 1024;
const _: () = assert!(
GAP < SYNTHETIC_PAD_CAP_BYTES,
"test premise: each entry must stay well under the per-entry cap on its own"
);
let entries_needed = CUMULATIVE_SYNTHETIC_CAP_BYTES / GAP + 1;
let entry_count = usize::try_from(entries_needed).unwrap() + 20;
let mut data = Vec::new();
for i in 0..entry_count {
data.extend(gnu_sparse_bomb_entry(
format!("small{i}.bin").as_bytes(),
HDR_BLOCK as u64 + GAP,
));
}
data.extend(std::iter::repeat_n(0u8, HDR_BLOCK * 2));
let (reader, budget) = budgeted_reader(Cursor::new(data), 4096);
let mut archive = tar::Archive::new(reader);
let mut entries = budgeted_tar_entries(&mut archive, budget).unwrap();
let (yielded, violation) = drain_all_entries(&mut entries);
let err = violation.expect(
"many sub-cap synthetic contributions must still sum past the cumulative budget, \
not silently drain in full",
);
assert!(
budget_violation(&err).is_some(),
"must be recognizable as a budget violation, got: {err:?}"
);
assert!(
yielded < entry_count,
"must fail before draining every one of the {entry_count} sub-cap entries, yielded \
{yielded}"
);
assert!(
yielded >= 2,
"expected several sub-cap entries to be individually drained before the cumulative \
cap trips, only {yielded} were"
);
}
}