#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
use exarch_core::ExtractionOptions;
use exarch_core::NoopProgress;
use exarch_core::SecurityConfig;
use exarch_core::formats::ArchiveFormat;
use exarch_core::formats::TarArchive;
use proptest::prelude::*;
use proptest::strategy::ValueTree;
use proptest::test_runner::TestRunner;
use std::io::Cursor;
use std::io::Read;
use std::io::Write;
const BLOCK: usize = 512;
const PEAK_BYTES_CEILING: usize = 32 * 1024 * 1024;
fn header(name: &[u8], size: u64, typeflag: u8, magic: Magic) -> Vec<u8> {
let mut h = vec![0u8; BLOCK];
let name_len = name.len().min(100);
h[..name_len].copy_from_slice(&name[..name_len]);
h[100..108].copy_from_slice(b"0000644\0");
h[108..116].copy_from_slice(b"0000000\0");
h[116..124].copy_from_slice(b"0000000\0");
h[124..136].copy_from_slice(format!("{size:011o}\0").as_bytes());
h[136..148].copy_from_slice(b"00000000000\0");
h[156] = typeflag;
match magic {
Magic::Gnu => {
h[257..263].copy_from_slice(b"ustar ");
h[263..265].copy_from_slice(b" \0");
}
Magic::Ustar => {
h[257..263].copy_from_slice(b"ustar\0");
h[263..265].copy_from_slice(b"00");
}
Magic::Invalid => {} }
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());
h
}
fn pad_to_block(out: &mut Vec<u8>, data: &[u8]) {
out.extend_from_slice(data);
let rem = data.len() % BLOCK;
if rem != 0 {
out.extend(std::iter::repeat_n(0u8, BLOCK - rem));
}
}
fn pax_record(key: &[u8], value: &[u8]) -> Vec<u8> {
let base = key.len() + value.len() + 3;
let mut len = base + 1;
loop {
let candidate_len = len.to_string().len() + base;
if candidate_len == len {
break;
}
len = candidate_len;
}
let mut record = format!("{len} ").into_bytes();
record.extend_from_slice(key);
record.push(b'=');
record.extend_from_slice(value);
record.push(b'\n');
record
}
#[derive(Clone, Copy)]
enum Magic {
Gnu,
Ustar,
Invalid,
}
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))
}
fn gnu_sparse_header(name: &[u8], size_field: u64, realsize: u64, gap: u64) -> Vec<u8> {
let mut h = vec![0u8; BLOCK];
let name_len = name.len().min(100);
h[..name_len].copy_from_slice(&name[..name_len]);
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(size_field, 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(size_field, 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());
h
}
fn measure_extract_peak_bytes(bytes: &[u8], budget: u64) -> usize {
let profiler = dhat::Profiler::builder().testing().build();
{
let temp = tempfile::tempdir().unwrap();
let config = SecurityConfig::default()
.with_max_tar_metadata_bytes(budget)
.validate()
.unwrap();
let mut archive = TarArchive::new(Cursor::new(bytes));
let _ = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut NoopProgress,
);
}
let stats = dhat::HeapStats::get();
drop(profiler);
stats.max_bytes
}
fn measure_list_peak_bytes(bytes: &[u8], budget: u64) -> usize {
let profiler = dhat::Profiler::builder().testing().build();
{
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("archive.tar");
std::fs::File::create(&path)
.unwrap()
.write_all(bytes)
.unwrap();
let config = SecurityConfig::default().with_max_tar_metadata_bytes(budget);
let _ = exarch_core::list_archive(&path, &config);
}
let stats = dhat::HeapStats::get();
drop(profiler);
stats.max_bytes
}
fn measure_verify_peak_bytes(bytes: &[u8], budget: u64) -> usize {
let profiler = dhat::Profiler::builder().testing().build();
{
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("archive.tar");
std::fs::File::create(&path)
.unwrap()
.write_all(bytes)
.unwrap();
let config = SecurityConfig::default().with_max_tar_metadata_bytes(budget);
let _ = exarch_core::verify_archive(&path, &config);
}
let stats = dhat::HeapStats::get();
drop(profiler);
stats.max_bytes
}
fn s1_shape(bomb_real_bytes: usize) -> Vec<u8> {
let mut out = Vec::new();
let pax_body = b"9 size=0\n".to_vec();
out.extend_from_slice(&header(
b"PaxHeaders/decoy",
pax_body.len() as u64,
b'x',
Magic::Ustar,
));
pad_to_block(&mut out, &pax_body);
out.extend_from_slice(&header(b"decoy.txt", 4096, b'0', Magic::Gnu));
out.extend_from_slice(&header(
b"././@LongLink",
bomb_real_bytes as u64,
b'L',
Magic::Gnu,
));
pad_to_block(&mut out, &vec![b'A'; bomb_real_bytes]);
out
}
fn s2_shape(bomb_real_bytes: usize) -> Vec<u8> {
let mut out = Vec::new();
let pax_body = pax_record(b"size", b"0");
out.extend_from_slice(&header(
b"PaxHeaders/decoy",
pax_body.len() as u64,
b'x',
Magic::Invalid,
));
pad_to_block(&mut out, &pax_body);
out.extend_from_slice(&header(b"decoy.txt", 512, b'0', Magic::Gnu));
pad_to_block(&mut out, &vec![b'D'; 512]);
out.extend_from_slice(&header(
b"././@LongLink",
bomb_real_bytes as u64,
b'L',
Magic::Gnu,
));
pad_to_block(&mut out, &vec![b'A'; bomb_real_bytes]);
out
}
fn s3_shape(bomb_real_bytes: usize) -> Vec<u8> {
let mut out = Vec::new();
let pax_body = pax_record(b"size", b"0");
out.extend_from_slice(&header(
b"PaxHeaders/decoy",
pax_body.len() as u64,
b'x',
Magic::Ustar,
));
pad_to_block(&mut out, &pax_body);
let global_body = pax_record(b"comment", b"hi");
out.extend_from_slice(&header(
b"././@PaxHeader",
global_body.len() as u64,
b'g',
Magic::Gnu,
));
pad_to_block(&mut out, &global_body);
out.extend_from_slice(&header(b"decoy.txt", 4096, b'0', Magic::Gnu));
pad_to_block(&mut out, &vec![b'D'; 4096]);
out.extend_from_slice(&header(
b"././@LongLink",
bomb_real_bytes as u64,
b'L',
Magic::Gnu,
));
pad_to_block(&mut out, &vec![b'A'; bomb_real_bytes]);
out
}
#[derive(Debug, Clone)]
struct RandomStep {
typeflag: u8,
magic: u8, declared_size: u64,
real_bytes: usize,
sparse_realsize: u64,
}
fn random_step_strategy() -> impl Strategy<Value = RandomStep> {
(
prop::sample::select(vec![b'L', b'K', b'x', b'g', b'0', b'S', b'5']),
0u8..3,
0u64..(4u64 * 1024 * 1024 * 1024),
0usize..8192,
any::<u64>(),
)
.prop_map(
|(typeflag, magic, declared_size, real_bytes, sparse_realsize)| RandomStep {
typeflag,
magic,
declared_size,
real_bytes,
sparse_realsize,
},
)
}
fn magic_from_u8(m: u8) -> Magic {
match m {
0 => Magic::Gnu,
1 => Magic::Ustar,
_ => Magic::Invalid,
}
}
fn build_from_steps(steps: &[RandomStep]) -> Vec<u8> {
let mut out = Vec::new();
for (i, step) in steps.iter().enumerate() {
let name = format!("entry-{i}");
if step.typeflag == b'S' {
let size_field = step.real_bytes as u64;
let gap = step.sparse_realsize.saturating_sub(size_field);
out.extend_from_slice(&gnu_sparse_header(
name.as_bytes(),
size_field,
step.sparse_realsize,
gap,
));
} else {
out.extend_from_slice(&header(
name.as_bytes(),
step.declared_size,
step.typeflag,
magic_from_u8(step.magic),
));
}
pad_to_block(&mut out, &vec![b'A'; step.real_bytes]);
}
out
}
#[test]
fn tar_metadata_bomb_allocations_stay_bounded() {
{
let profiler = dhat::Profiler::builder().testing().build();
{
let mut sink = vec![0u8; PEAK_BYTES_CEILING + 1024 * 1024];
let mut reader = Cursor::new(&mut sink);
let mut discard = [0u8; 1];
let _ = reader.read(&mut discard);
}
let stats = dhat::HeapStats::get();
drop(profiler);
assert!(
stats.max_bytes > PEAK_BYTES_CEILING,
"harness failed to detect a deliberate over-ceiling allocation: {}",
stats.max_bytes
);
}
for (label, bytes) in [
("S1", s1_shape(64 * 1024)),
("S2", s2_shape(64 * 1024)),
("S3", s3_shape(64 * 1024)),
] {
let peak = measure_extract_peak_bytes(&bytes, 4096);
assert!(
peak < PEAK_BYTES_CEILING,
"{label} shape (extract): peak {peak} bytes exceeded the {PEAK_BYTES_CEILING}-byte \
ceiling"
);
let peak = measure_list_peak_bytes(&bytes, 4096);
assert!(
peak < PEAK_BYTES_CEILING,
"{label} shape (list): peak {peak} bytes exceeded the {PEAK_BYTES_CEILING}-byte \
ceiling"
);
let peak = measure_verify_peak_bytes(&bytes, 4096);
assert!(
peak < PEAK_BYTES_CEILING,
"{label} shape (verify): peak {peak} bytes exceeded the {PEAK_BYTES_CEILING}-byte \
ceiling"
);
}
{
let realsize = 1u64 << 62;
let bytes = gnu_sparse_header(
b"sparsebomb.bin",
BLOCK as u64,
realsize,
realsize - BLOCK as u64,
);
let mut archive_bytes = bytes;
archive_bytes.extend(std::iter::repeat_n(0u8, BLOCK)); archive_bytes.extend(std::iter::repeat_n(0u8, BLOCK * 2));
for (label, peak) in [
("extract", measure_extract_peak_bytes(&archive_bytes, 4096)),
("list", measure_list_peak_bytes(&archive_bytes, 4096)),
("verify", measure_verify_peak_bytes(&archive_bytes, 4096)),
] {
assert!(
peak < PEAK_BYTES_CEILING,
"GNU sparse realsize bomb ({label}): peak {peak} bytes exceeded the \
{PEAK_BYTES_CEILING}-byte ceiling"
);
}
}
let mut runner = TestRunner::default();
let strategy = prop::collection::vec(random_step_strategy(), 1..12);
for _ in 0..100 {
let tree = strategy.new_tree(&mut runner).unwrap();
let steps = tree.current();
let bytes = build_from_steps(&steps);
let peak = measure_extract_peak_bytes(&bytes, 8192);
assert!(
peak < PEAK_BYTES_CEILING,
"random archive (steps: {steps:?}, extract): peak {peak} bytes exceeded the \
{PEAK_BYTES_CEILING}-byte ceiling"
);
let peak = measure_list_peak_bytes(&bytes, 8192);
assert!(
peak < PEAK_BYTES_CEILING,
"random archive (steps: {steps:?}, list): peak {peak} bytes exceeded the \
{PEAK_BYTES_CEILING}-byte ceiling"
);
let peak = measure_verify_peak_bytes(&bytes, 8192);
assert!(
peak < PEAK_BYTES_CEILING,
"random archive (steps: {steps:?}, verify): peak {peak} bytes exceeded the \
{PEAK_BYTES_CEILING}-byte ceiling"
);
}
}