use std::fs;
use std::io::{self, Read, Seek, Write};
use std::path::{Component, Path};
use crate::setup_core::checksum::continue_crc32;
use crate::setup_core::error::{Error, ReasonCode, Result};
const BLOCK: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
File,
Directory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub path: String,
pub kind: Kind,
pub mode: u32,
pub size: u64,
}
impl Entry {
#[must_use]
pub const fn is_executable(&self) -> bool {
self.mode & 0o111 != 0
}
}
fn refuse(detail: impl Into<String>) -> Error {
Error::new(ReasonCode::IntegrityMismatch, detail)
}
fn from_io(detail: &str, error: io::Error) -> Error {
Error::new(ReasonCode::StateUnavailable, format!("{detail}: {error}")).with_source(error)
}
fn from_source_io(detail: &str, error: io::Error) -> Error {
Error::new(ReasonCode::IntegrityMismatch, format!("{detail}: {error}")).with_source(error)
}
pub struct Gunzip<R: Read> {
inner: R,
state: Box<miniz_oxide::inflate::stream::InflateState>,
input: Vec<u8>,
filled: usize,
consumed: usize,
finished: bool,
crc: u32,
length: u64,
}
impl<R: Read> Gunzip<R> {
pub fn new(mut inner: R) -> Result<Self> {
let mut head = [0_u8; 10];
inner
.read_exact(&mut head)
.map_err(|error| from_source_io("gzip header could not be read", error))?;
if head[0] != 0x1F || head[1] != 0x8B {
return Err(refuse(format!(
"not a gzip member: magic {:#04x}{:02x}",
head[0], head[1]
)));
}
if head[2] != 8 {
return Err(refuse(format!(
"gzip compression method {} is not DEFLATE",
head[2]
)));
}
let flags = head[3];
if flags & 0b1110_0000 != 0 {
return Err(refuse(format!(
"gzip reserved flag bits set: {flags:#010b}"
)));
}
if flags & 0b0000_0100 != 0 {
let mut length = [0_u8; 2];
inner.read_exact(&mut length).map_err(|error| {
from_source_io("gzip extra field length could not be read", error)
})?;
let mut extra = vec![0_u8; usize::from(u16::from_le_bytes(length))];
inner
.read_exact(&mut extra)
.map_err(|error| from_source_io("gzip extra field could not be read", error))?;
}
for (bit, what) in [(0b0000_1000_u8, "name"), (0b0001_0000, "comment")] {
if flags & bit != 0 {
let mut byte = [0_u8; 1];
loop {
inner.read_exact(&mut byte).map_err(|error| {
from_source_io(&format!("gzip {what} field could not be read"), error)
})?;
if byte[0] == 0 {
break;
}
}
}
}
if flags & 0b0000_0010 != 0 {
let mut check = [0_u8; 2];
inner
.read_exact(&mut check)
.map_err(|error| from_source_io("gzip header checksum could not be read", error))?;
}
Ok(Self {
inner,
state: miniz_oxide::inflate::stream::InflateState::new_boxed(
miniz_oxide::DataFormat::Raw,
),
input: vec![0_u8; 64 * 1024],
filled: 0,
consumed: 0,
finished: false,
crc: 0,
length: 0,
})
}
fn finish(&mut self) -> io::Result<()> {
let mut trailer = [0_u8; 8];
let mut have = self.filled - self.consumed;
let carried = have.min(8);
trailer[..carried].copy_from_slice(&self.input[self.consumed..self.consumed + carried]);
self.consumed += carried;
have = carried;
while have < 8 {
let read = self.inner.read(&mut trailer[have..])?;
if read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"gzip trailer is truncated",
));
}
have += read;
}
let stated_crc = u32::from_le_bytes([trailer[0], trailer[1], trailer[2], trailer[3]]);
let stated_length = u32::from_le_bytes([trailer[4], trailer[5], trailer[6], trailer[7]]);
if stated_crc != self.crc {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"gzip CRC-32 mismatch: stated {stated_crc:#010x}, inflated {:#010x}",
self.crc
),
));
}
#[allow(clippy::cast_possible_truncation)]
let low = self.length as u32;
if stated_length != low {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"gzip length mismatch: stated {stated_length}, inflated {}",
self.length
),
));
}
Ok(())
}
}
impl<R: Read> Read for Gunzip<R> {
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
if self.finished || out.is_empty() {
return Ok(0);
}
loop {
if self.consumed == self.filled {
self.filled = self.inner.read(&mut self.input)?;
self.consumed = 0;
if self.filled == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"gzip stream ended before the DEFLATE data did",
));
}
}
let result = miniz_oxide::inflate::stream::inflate(
&mut self.state,
&self.input[self.consumed..self.filled],
out,
miniz_oxide::MZFlush::None,
);
self.consumed += result.bytes_consumed;
let written = result.bytes_written;
if written > 0 {
self.crc = continue_crc32(self.crc, &out[..written]);
self.length = self.length.wrapping_add(written as u64);
}
match result.status {
Ok(miniz_oxide::MZStatus::StreamEnd) => {
self.finished = true;
self.finish()?;
return Ok(written);
}
Ok(_) => {
if written > 0 {
return Ok(written);
}
}
Err(error) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("DEFLATE stream is malformed: {error:?}"),
));
}
}
}
}
}
pub struct Tar<R: Read> {
inner: R,
remaining: u64,
padding: usize,
buffer: Box<[u8]>,
}
impl<R: Read> Tar<R> {
#[must_use]
pub fn new(inner: R) -> Self {
Self {
inner,
remaining: 0,
padding: 0,
buffer: vec![0_u8; 64 * 1024].into_boxed_slice(),
}
}
pub fn next_entry(&mut self) -> Result<Option<Entry>> {
self.skip_rest()?;
let mut long_name: Option<String> = None;
loop {
let Some(block) = self.read_block()? else {
return Ok(None);
};
verify_checksum(&block)?;
let magic = &block[257..263];
let gnu = magic == b"ustar ";
let posix = magic == b"ustar\0";
if !gnu && !posix {
return Err(refuse(format!(
"tar magic {:?} is neither POSIX ustar nor GNU tar",
String::from_utf8_lossy(magic)
)));
}
let size = octal(&block[124..136], "size")?;
let mode = u32::try_from(octal(&block[100..108], "mode")?).unwrap_or(0o644);
self.remaining = size;
self.padding = padding_for(size);
let flag = block[156];
match flag {
b'L' => {
if !gnu {
return Err(refuse(
"a GNU long-name header appeared in a POSIX ustar archive",
));
}
let mut raw = Vec::new();
self.copy_entry(&mut raw)?;
let text = String::from_utf8(raw)
.map_err(|_| refuse("a GNU long name is not valid UTF-8"))?;
long_name = Some(text.trim_end_matches('\0').to_owned());
}
b'x' | b'g' => {
let mut raw = Vec::new();
self.copy_entry(&mut raw)?;
for key in pax_keys(&raw)? {
if OVERRIDES_A_WRITE.contains(&key.as_str())
|| key.starts_with("GNU.sparse.")
{
return Err(refuse(format!(
"refusing a pax extended header carrying {key:?}: it would \
change what this reader writes or where, and extraction here \
acts only on the header fields it can see"
)));
}
}
}
b'0' | b'\0' | b'5' => {
let path = match long_name.take() {
Some(name) => name,
None => joined_name(&block, posix)?,
};
let kind = if flag == b'5' || path.ends_with('/') {
Kind::Directory
} else {
Kind::File
};
let path = check_relative(path.trim_end_matches('/'))?;
if kind == Kind::Directory && size != 0 {
return Err(refuse(format!("directory {path} carries {size} bytes")));
}
return Ok(Some(Entry {
path,
kind,
mode,
size,
}));
}
other => return Err(refuse(refusal_for(other))),
}
}
}
pub fn into_inner(self) -> R {
self.inner
}
pub fn copy_entry(&mut self, out: &mut impl Write) -> Result<()> {
while self.remaining > 0 {
let want = usize::try_from(self.remaining.min(self.buffer.len() as u64)).unwrap_or(1);
let read = self
.inner
.read(&mut self.buffer[..want])
.map_err(|error| from_source_io("archive content could not be read", error))?;
if read == 0 {
return Err(refuse("archive ended in the middle of an entry"));
}
out.write_all(&self.buffer[..read])
.map_err(|error| from_io("archive content could not be written", error))?;
self.remaining -= read as u64;
}
self.skip_padding()
}
fn skip_rest(&mut self) -> Result<()> {
let mut sink = io::sink();
if self.remaining > 0 {
self.copy_entry(&mut sink)
} else {
self.skip_padding()
}
}
fn skip_padding(&mut self) -> Result<()> {
if self.padding == 0 {
return Ok(());
}
let mut waste = [0_u8; BLOCK];
let take = self.padding;
self.padding = 0;
self.inner
.read_exact(&mut waste[..take])
.map_err(|error| from_source_io("archive padding could not be read", error))
}
fn read_block(&mut self) -> Result<Option<[u8; BLOCK]>> {
let mut block = [0_u8; BLOCK];
let mut have = 0;
while have < BLOCK {
let read = self
.inner
.read(&mut block[have..])
.map_err(|error| from_source_io("archive header could not be read", error))?;
if read == 0 {
if have == 0 {
return Ok(None);
}
return Err(refuse("archive ended in the middle of a header"));
}
have += read;
}
if block.iter().all(|byte| *byte == 0) {
return Ok(None);
}
Ok(Some(block))
}
}
const OVERRIDES_A_WRITE: &[&str] = &["path", "linkpath", "size", "SCHILY.filetype"];
fn pax_keys(payload: &[u8]) -> Result<Vec<String>> {
let mut keys = Vec::new();
let mut rest = payload;
while !rest.is_empty() {
let space = rest
.iter()
.position(|byte| *byte == b' ')
.ok_or_else(|| refuse("a pax record has no length field"))?;
let digits = std::str::from_utf8(&rest[..space])
.map_err(|_| refuse("a pax record length is not text"))?;
let length: usize = digits
.parse()
.map_err(|_| refuse(format!("a pax record length {digits:?} is not a number")))?;
if length <= space + 1 || length > rest.len() {
return Err(refuse(format!(
"a pax record claims {length} bytes, which does not fit what remains"
)));
}
let record = &rest[space + 1..length];
let equals = record
.iter()
.position(|byte| *byte == b'=')
.ok_or_else(|| refuse("a pax record has no key"))?;
keys.push(String::from_utf8_lossy(&record[..equals]).into_owned());
rest = &rest[length..];
}
Ok(keys)
}
fn refusal_for(flag: u8) -> String {
let what = match flag {
b'1' => "a hard link",
b'2' => "a symbolic link",
b'3' => "a character device",
b'4' => "a block device",
b'6' => "a FIFO",
b'7' => "a contiguous file",
b'K' => "a GNU long link name",
_ => "an entry type outside the format this reader accepts",
};
format!(
"refusing {what} (type flag {:?}): extraction here writes regular files and directories \
and nothing else, so that no entry can redirect a later write outside the destination",
char::from(flag)
)
}
fn verify_checksum(block: &[u8; BLOCK]) -> Result<()> {
let stated = octal(&block[148..156], "checksum")?;
let mut unsigned = 0_u64;
let mut signed = 0_i64;
for (index, byte) in block.iter().enumerate() {
let value = if (148..156).contains(&index) {
b' '
} else {
*byte
};
unsigned += u64::from(value);
signed += i64::from(value.cast_signed());
}
if stated == unsigned || i64::try_from(stated).is_ok_and(|want| want == signed) {
return Ok(());
}
Err(refuse(format!(
"tar header checksum mismatch: stated {stated}, computed {unsigned}"
)))
}
fn octal(field: &[u8], what: &str) -> Result<u64> {
let text: Vec<u8> = field
.iter()
.copied()
.take_while(|byte| *byte != 0 && *byte != b' ')
.skip_while(|byte| *byte == b' ')
.collect();
if text.is_empty() {
return Ok(0);
}
let text = std::str::from_utf8(&text)
.map_err(|_| refuse(format!("tar {what} field is not ASCII octal")))?;
u64::from_str_radix(text, 8)
.map_err(|_| refuse(format!("tar {what} field {text:?} is not octal")))
}
fn joined_name(block: &[u8; BLOCK], posix: bool) -> Result<String> {
let name = field_text(&block[0..100], "name")?;
if !posix {
return Ok(name);
}
let prefix = field_text(&block[345..500], "prefix")?;
if prefix.is_empty() {
Ok(name)
} else {
Ok(format!("{prefix}/{name}"))
}
}
fn field_text(field: &[u8], what: &str) -> Result<String> {
let end = field
.iter()
.position(|byte| *byte == 0)
.unwrap_or(field.len());
std::str::from_utf8(&field[..end])
.map(str::to_owned)
.map_err(|_| refuse(format!("tar {what} field is not valid UTF-8")))
}
fn padding_for(size: u64) -> usize {
let remainder = usize::try_from(size % BLOCK as u64).unwrap_or(0);
if remainder == 0 { 0 } else { BLOCK - remainder }
}
fn check_relative(path: &str) -> Result<String> {
if path.is_empty() {
return Err(refuse("archive entry has an empty path"));
}
if path.starts_with('/') || path.starts_with('\\') {
return Err(refuse(format!("archive entry {path} is an absolute path")));
}
if path.contains('\\') {
return Err(refuse(format!(
"archive entry {path} contains a backslash, which names a different file on each system"
)));
}
if path.contains('\0') {
return Err(refuse("archive entry path contains a NUL byte"));
}
if path.as_bytes().get(1) == Some(&b':') {
return Err(refuse(format!(
"archive entry {path} carries a drive letter"
)));
}
for part in path.split('/') {
if part == ".." {
return Err(refuse(format!(
"archive entry {path} climbs out of the destination"
)));
}
}
Ok(path.to_owned())
}
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub entries: u64,
pub bytes: u64,
}
pub fn extract_gzip_tar(
source: impl Read,
destination: &Path,
limits: Limits,
) -> Result<Vec<Entry>> {
let mut tar = Tar::new(Gunzip::new(source)?);
let mut written = Vec::new();
let mut total = 0_u64;
fs::create_dir_all(destination)
.map_err(|error| from_io("destination could not be created", error))?;
while let Some(entry) = tar.next_entry()? {
if written.len() as u64 >= limits.entries {
return Err(refuse(format!(
"archive holds more than the {} entries this extraction allows",
limits.entries
)));
}
total = total.saturating_add(entry.size);
if total > limits.bytes {
return Err(refuse(format!(
"archive inflates past the {} bytes this extraction allows",
limits.bytes
)));
}
let path = destination.join(&entry.path);
guard_within(destination, &path)?;
match entry.kind {
Kind::Directory => {
fs::create_dir_all(&path)
.map_err(|error| from_io("archive directory could not be created", error))?;
}
Kind::File => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
from_io("archive parent directory could not be created", error)
})?;
}
let mut file = fs::File::create(&path)
.map_err(|error| from_io("archive file could not be created", error))?;
tar.copy_entry(&mut file)?;
file.sync_all()
.map_err(|error| from_io("archive file could not be flushed", error))?;
apply_mode(&path, entry.mode)?;
}
}
written.push(entry);
}
let mut rest = tar.into_inner();
io::copy(&mut rest, &mut io::sink())
.map_err(|error| refuse(format!("archive did not end cleanly: {error}")))?;
Ok(written)
}
mod zip {
use super::{
Entry, Kind, Limits, check_relative, from_io, from_source_io, guard_within, refuse,
};
use crate::setup_core::checksum::continue_crc32;
use crate::setup_core::error::Result;
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
const EOCD_LEN: usize = 22;
const EOCD_SEARCH: usize = EOCD_LEN + u16::MAX as usize;
const MAX_CENTRAL_BYTES: u64 = 16 * 1024 * 1024;
const STORED: u16 = 0;
const DEFLATE: u16 = 8;
fn u16_at(bytes: &[u8], at: usize) -> Result<u16> {
bytes
.get(at..at + 2)
.and_then(|slice| <[u8; 2]>::try_from(slice).ok())
.map(u16::from_le_bytes)
.ok_or_else(|| refuse("zip record ends inside a field"))
}
fn u32_at(bytes: &[u8], at: usize) -> Result<u32> {
bytes
.get(at..at + 4)
.and_then(|slice| <[u8; 4]>::try_from(slice).ok())
.map(u32::from_le_bytes)
.ok_or_else(|| refuse("zip record ends inside a field"))
}
struct Central {
path: String,
method: u16,
crc: u32,
compressed: u64,
uncompressed: u64,
offset: u64,
directory: bool,
}
fn locate(source: &mut (impl Read + Seek)) -> Result<(u64, u64, u64)> {
let end = source
.seek(SeekFrom::End(0))
.map_err(|error| from_source_io("zip length could not be read", error))?;
let window = EOCD_SEARCH.min(usize::try_from(end).unwrap_or(usize::MAX));
let from = end.saturating_sub(window as u64);
source
.seek(SeekFrom::Start(from))
.map_err(|error| from_source_io("zip tail could not be reached", error))?;
let mut tail = vec![0_u8; window];
source
.read_exact(&mut tail)
.map_err(|error| from_source_io("zip tail could not be read", error))?;
let at = (0..=tail.len().saturating_sub(EOCD_LEN))
.rev()
.find(|&at| tail.get(at..at + 4) == Some(&[0x50, 0x4B, 0x05, 0x06]))
.ok_or_else(|| refuse("not a zip archive: no end-of-central-directory record"))?;
let record = &tail[at..];
if u32_at(record, 16)? == u32::MAX || u16_at(record, 10)? == u16::MAX {
return Err(refuse(
"zip uses the Zip64 format, which this reader does not accept",
));
}
if u16_at(record, 4)? != 0 || u16_at(record, 6)? != 0 {
return Err(refuse("zip spans multiple disks"));
}
Ok((
u64::from(u16_at(record, 10)?),
u64::from(u32_at(record, 12)?),
u64::from(u32_at(record, 16)?),
))
}
fn entries(bytes: &[u8], count: u64) -> Result<Vec<Central>> {
let mut found = Vec::new();
let mut at = 0_usize;
while (found.len() as u64) < count {
let header = bytes
.get(at..)
.ok_or_else(|| refuse("zip central directory ends early"))?;
if header.get(..4) != Some(&[0x50, 0x4B, 0x01, 0x02]) {
return Err(refuse("zip central directory header is malformed"));
}
let flags = u16_at(header, 8)?;
if flags & 0b0000_0001 != 0 {
return Err(refuse("zip entry is encrypted"));
}
if flags & 0b0000_1000 != 0 {
return Err(refuse(
"zip entry carries a data descriptor, whose sizes this reader will not trust",
));
}
let name_len = usize::from(u16_at(header, 28)?);
let extra_len = usize::from(u16_at(header, 30)?);
let comment_len = usize::from(u16_at(header, 32)?);
let raw = header
.get(46..46 + name_len)
.ok_or_else(|| refuse("zip entry name is truncated"))?;
let name = std::str::from_utf8(raw)
.map_err(|_| refuse("zip entry name is not valid UTF-8"))?;
let compressed = u64::from(u32_at(header, 20)?);
let uncompressed = u64::from(u32_at(header, 24)?);
let offset = u64::from(u32_at(header, 42)?);
if compressed == u64::from(u32::MAX)
|| uncompressed == u64::from(u32::MAX)
|| offset == u64::from(u32::MAX)
{
return Err(refuse(
"zip entry uses a Zip64 extended field, which this reader does not accept",
));
}
let directory = name.ends_with('/');
let path = check_relative(name.trim_end_matches('/'))?;
found.push(Central {
path,
method: u16_at(header, 10)?,
crc: u32_at(header, 16)?,
compressed,
uncompressed,
offset,
directory,
});
at += 46 + name_len + extra_len + comment_len;
}
Ok(found)
}
fn write_entry(
source: &mut (impl Read + Seek),
central: &Central,
into: &mut fs::File,
) -> Result<()> {
source
.seek(SeekFrom::Start(central.offset))
.map_err(|error| from_source_io("zip entry could not be reached", error))?;
let mut local = [0_u8; 30];
source
.read_exact(&mut local)
.map_err(|error| from_source_io("zip local header could not be read", error))?;
if local.get(..4) != Some(&[0x50, 0x4B, 0x03, 0x04]) {
return Err(refuse(format!(
"{} does not start with a local file header",
central.path
)));
}
let skip = u64::from(u16_at(&local, 26)?) + u64::from(u16_at(&local, 28)?);
source
.seek(SeekFrom::Current(i64::try_from(skip).map_err(|_| {
refuse("zip local header is implausibly long")
})?))
.map_err(|error| from_source_io("zip entry body could not be reached", error))?;
let mut taken = source.take(central.compressed);
let (crc, length) = match central.method {
STORED => copy_checked(&mut taken, into)?,
DEFLATE => inflate_checked(&mut taken, into)?,
other => {
return Err(refuse(format!(
"{} uses zip compression method {other}, and this reader accepts only stored \
and deflate",
central.path
)));
}
};
if crc != central.crc {
return Err(refuse(format!(
"{} fails its CRC-32: the archive states {:#010x} and the bytes give {crc:#010x}",
central.path, central.crc
)));
}
if length != central.uncompressed {
return Err(refuse(format!(
"{} is {length} bytes and the archive states {}",
central.path, central.uncompressed
)));
}
Ok(())
}
fn copy_checked(from: &mut impl Read, into: &mut fs::File) -> Result<(u32, u64)> {
let mut buffer = vec![0_u8; 64 * 1024];
let (mut crc, mut length) = (0_u32, 0_u64);
loop {
let read = from
.read(&mut buffer)
.map_err(|error| from_source_io("zip entry could not be read", error))?;
if read == 0 {
return Ok((crc, length));
}
crc = continue_crc32(crc, &buffer[..read]);
length = length.saturating_add(read as u64);
into.write_all(&buffer[..read])
.map_err(|error| from_io("zip entry could not be written", error))?;
}
}
fn inflate_checked(from: &mut impl Read, into: &mut fs::File) -> Result<(u32, u64)> {
let mut state =
miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Raw);
let mut input = vec![0_u8; 64 * 1024];
let mut output = vec![0_u8; 256 * 1024];
let (mut crc, mut length) = (0_u32, 0_u64);
let (mut filled, mut consumed) = (0_usize, 0_usize);
loop {
if consumed == filled {
filled = from
.read(&mut input)
.map_err(|error| from_source_io("zip entry could not be read", error))?;
consumed = 0;
if filled == 0 {
return Err(refuse("zip entry ended before its DEFLATE stream did"));
}
}
let result = miniz_oxide::inflate::stream::inflate(
&mut state,
&input[consumed..filled],
&mut output,
miniz_oxide::MZFlush::None,
);
consumed += result.bytes_consumed;
let written = result.bytes_written;
if written > 0 {
crc = continue_crc32(crc, &output[..written]);
length = length.saturating_add(written as u64);
into.write_all(&output[..written])
.map_err(|error| from_io("zip entry could not be written", error))?;
}
match result.status {
Ok(miniz_oxide::MZStatus::StreamEnd) => return Ok((crc, length)),
Ok(_) => {}
Err(error) => {
return Err(refuse(format!(
"zip entry's DEFLATE stream is malformed: {error:?}"
)));
}
}
}
}
pub fn extract(
mut source: impl Read + Seek,
destination: &Path,
limits: Limits,
) -> Result<Vec<Entry>> {
let (count, size, at) = locate(&mut source)?;
if count > limits.entries {
return Err(refuse(format!(
"archive holds more than the {} entries this extraction allows",
limits.entries
)));
}
if size > MAX_CENTRAL_BYTES {
return Err(refuse("zip central directory is implausibly large"));
}
source
.seek(SeekFrom::Start(at))
.map_err(|error| from_source_io("zip central directory could not be reached", error))?;
let mut central = vec![0_u8; usize::try_from(size).unwrap_or(0)];
source
.read_exact(&mut central)
.map_err(|error| from_source_io("zip central directory could not be read", error))?;
let listed = entries(¢ral, count)?;
let total: u64 = listed.iter().map(|entry| entry.uncompressed).sum();
if total > limits.bytes {
return Err(refuse(format!(
"archive inflates past the {} bytes this extraction allows",
limits.bytes
)));
}
fs::create_dir_all(destination)
.map_err(|error| from_io("destination could not be created", error))?;
let mut written = Vec::with_capacity(listed.len());
for entry in listed {
let path = destination.join(&entry.path);
guard_within(destination, &path)?;
if entry.directory {
fs::create_dir_all(&path)
.map_err(|error| from_io("archive directory could not be created", error))?;
written.push(Entry {
path: entry.path,
kind: Kind::Directory,
mode: 0o755,
size: 0,
});
continue;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
from_io("archive parent directory could not be created", error)
})?;
}
let mut file = fs::File::create(&path)
.map_err(|error| from_io("archive file could not be created", error))?;
write_entry(&mut source, &entry, &mut file)?;
file.sync_all()
.map_err(|error| from_io("archive file could not be flushed", error))?;
written.push(Entry {
path: entry.path,
kind: Kind::File,
mode: 0o644,
size: entry.uncompressed,
});
}
Ok(written)
}
}
pub fn extract_zip(
source: impl Read + Seek,
destination: &Path,
limits: Limits,
) -> Result<Vec<Entry>> {
zip::extract(source, destination, limits)
}
fn guard_within(destination: &Path, candidate: &Path) -> Result<()> {
let escapes = candidate
.components()
.any(|component| matches!(component, Component::ParentDir));
if escapes || !candidate.starts_with(destination) {
return Err(refuse(format!(
"{} is not inside {}",
candidate.display(),
destination.display()
)));
}
Ok(())
}
#[cfg(unix)]
fn apply_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let bits = if mode & 0o111 == 0 { 0o644 } else { 0o755 };
fs::set_permissions(path, fs::Permissions::from_mode(bits))
.map_err(|error| from_io("archive file permissions could not be set", error))
}
#[cfg(not(unix))]
fn apply_mode(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
pub fn place_executable(source: impl Read, destination: &Path) -> Result<u64> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|error| from_io("destination directory could not be created", error))?;
}
let mut file = fs::File::create(destination)
.map_err(|error| from_io("executable could not be created", error))?;
let mut source = source;
let written = io::copy(&mut source, &mut file)
.map_err(|error| from_io("executable could not be written", error))?;
file.sync_all()
.map_err(|error| from_io("executable could not be flushed", error))?;
apply_mode(destination, 0o755)?;
Ok(written)
}
pub mod build {
use super::BLOCK;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
Posix,
Gnu,
}
impl Dialect {
const fn magic(self) -> &'static [u8; 8] {
match self {
Self::Posix => b"ustar\x0000",
Self::Gnu => b"ustar \0",
}
}
}
pub struct Item<'a> {
pub path: &'a str,
pub body: &'a [u8],
pub mode: u32,
pub directory: bool,
}
impl<'a> Item<'a> {
#[must_use]
pub const fn file(path: &'a str, body: &'a [u8], mode: u32) -> Self {
Self {
path,
body,
mode,
directory: false,
}
}
#[must_use]
pub const fn directory(path: &'a str) -> Self {
Self {
path,
body: &[],
mode: 0o755,
directory: true,
}
}
}
fn write_field(block: &mut [u8; BLOCK], at: usize, text: &[u8]) {
block[at..at + text.len()].copy_from_slice(text);
}
fn header(path: &str, size: u64, mode: u32, flag: u8, dialect: Dialect) -> [u8; BLOCK] {
let mut block = [0_u8; BLOCK];
let (prefix, name) = match dialect {
Dialect::Posix if path.len() > 100 => match path[..100].rfind('/') {
Some(cut) => (&path[..cut], &path[cut + 1..]),
None => ("", path),
},
_ => ("", path),
};
write_field(&mut block, 0, name.as_bytes());
write_field(&mut block, 100, format!("{mode:07o}\0").as_bytes());
write_field(&mut block, 108, b"0000000\0");
write_field(&mut block, 116, b"0000000\0");
write_field(&mut block, 124, format!("{size:011o}\0").as_bytes());
write_field(&mut block, 136, b"00000000000\0");
block[156] = flag;
write_field(&mut block, 257, dialect.magic());
if !prefix.is_empty() {
write_field(&mut block, 345, prefix.as_bytes());
}
write_field(&mut block, 148, b" ");
let sum: u64 = block.iter().map(|byte| u64::from(*byte)).sum();
write_field(&mut block, 148, format!("{sum:06o}\0 ").as_bytes());
block
}
fn push_entry(out: &mut Vec<u8>, block: &[u8; BLOCK], body: &[u8]) {
out.extend_from_slice(block);
out.extend_from_slice(body);
let remainder = body.len() % BLOCK;
if remainder != 0 {
out.extend(std::iter::repeat_n(0_u8, BLOCK - remainder));
}
}
#[must_use]
pub fn tar(items: &[Item<'_>], dialect: Dialect) -> Vec<u8> {
let mut out = Vec::new();
for item in items {
let stored = if item.directory {
format!("{}/", item.path.trim_end_matches('/'))
} else {
item.path.to_owned()
};
if dialect == Dialect::Gnu && stored.len() > 100 {
let mut name = stored.clone().into_bytes();
name.push(0);
let long = header("././@LongLink", name.len() as u64, 0o644, b'L', dialect);
push_entry(&mut out, &long, &name);
}
let flag = if item.directory { b'5' } else { b'0' };
let block = header(&stored, item.body.len() as u64, item.mode, flag, dialect);
push_entry(&mut out, &block, item.body);
}
out.extend(std::iter::repeat_n(0_u8, BLOCK * 2));
out
}
#[must_use]
pub fn gzip(payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x1F, 0x8B, 8, 0, 0, 0, 0, 0, 0, 0xFF];
out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(payload, 6));
out.extend_from_slice(&crate::setup_core::checksum::crc32(payload).to_le_bytes());
#[allow(clippy::cast_possible_truncation)]
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out
}
#[must_use]
pub fn gzip_tar(items: &[Item<'_>], dialect: Dialect) -> Vec<u8> {
gzip(&tar(items, dialect))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use super::build::{Dialect, Item, gzip, gzip_tar, tar};
use super::*;
const ROOMY: Limits = Limits {
entries: 4096,
bytes: 1 << 30,
};
#[allow(
clippy::cast_possible_truncation,
reason = "a fixture archive, whose entries are a few kilobytes"
)]
fn zip_bytes(items: &[(&str, &[u8], bool)]) -> Vec<u8> {
use crate::setup_core::checksum::crc32;
let mut out: Vec<u8> = Vec::new();
let mut central: Vec<u8> = Vec::new();
let mut count = 0_u16;
for (name, body, stored) in items {
let directory = name.ends_with('/');
let payload: Vec<u8> = if directory || *stored {
body.to_vec()
} else {
miniz_oxide::deflate::compress_to_vec(body, 6)
};
let method: u16 = if directory || *stored { 0 } else { 8 };
let offset = out.len() as u32;
let sum = crc32(body);
out.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]);
out.extend_from_slice(&20_u16.to_le_bytes());
out.extend_from_slice(&0_u16.to_le_bytes());
out.extend_from_slice(&method.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&sum.to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0_u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(&payload);
central.extend_from_slice(&[0x50, 0x4B, 0x01, 0x02]);
central.extend_from_slice(&20_u16.to_le_bytes());
central.extend_from_slice(&20_u16.to_le_bytes());
central.extend_from_slice(&0_u16.to_le_bytes());
central.extend_from_slice(&method.to_le_bytes());
central.extend_from_slice(&0_u32.to_le_bytes());
central.extend_from_slice(&sum.to_le_bytes());
central.extend_from_slice(&(payload.len() as u32).to_le_bytes());
central.extend_from_slice(&(body.len() as u32).to_le_bytes());
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
central.extend_from_slice(&0_u16.to_le_bytes());
central.extend_from_slice(&0_u16.to_le_bytes());
central.extend_from_slice(&0_u16.to_le_bytes());
central.extend_from_slice(&0_u16.to_le_bytes());
central.extend_from_slice(&0_u32.to_le_bytes());
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name.as_bytes());
count += 1;
}
let at = out.len() as u32;
let size = central.len() as u32;
out.extend_from_slice(¢ral);
out.extend_from_slice(&[0x50, 0x4B, 0x05, 0x06]);
out.extend_from_slice(&0_u16.to_le_bytes());
out.extend_from_slice(&0_u16.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&at.to_le_bytes());
out.extend_from_slice(&0_u16.to_le_bytes());
out
}
fn scratch(name: &str) -> PathBuf {
let path =
std::env::temp_dir().join(format!("setup-core-archive-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&path);
path
}
fn read(root: &Path, relative: &str) -> Vec<u8> {
fs::read(root.join(relative)).unwrap()
}
#[test]
fn a_posix_archive_shaped_like_claudes_round_trips() {
let archive = gzip_tar(
&[
Item::file("package/claude", b"ELF...", 0o755),
Item::file("package/package.json", b"{}", 0o644),
Item::file("package/README.md", b"read me", 0o644),
Item::file("package/LICENSE.md", b"licence", 0o644),
],
Dialect::Posix,
);
let into = scratch("posix");
let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap();
assert_eq!(written.len(), 4);
assert_eq!(read(&into, "package/claude"), b"ELF...");
assert_eq!(read(&into, "package/package.json"), b"{}");
assert!(written[0].is_executable());
assert!(!written[1].is_executable());
fs::remove_dir_all(&into).unwrap();
}
#[test]
fn a_gnu_archive_with_long_names_and_directories_round_trips() {
let long = "dist-package/node_modules/better-sqlite3/build/Release/obj.target/deps/sqlite3/very/deeply/nested/better_sqlite3.node";
assert!(
long.len() > 100,
"the fixture must exercise the long-name path"
);
let archive = gzip_tar(
&[
Item::directory("dist-package"),
Item::directory("dist-package/node_modules"),
Item::file("dist-package/cursor-agent", b"#!/bin/sh\n", 0o755),
Item::file(long, b"native module", 0o755),
],
Dialect::Gnu,
);
let into = scratch("gnu");
let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap();
assert_eq!(written.len(), 4);
assert_eq!(written[0].kind, Kind::Directory);
assert_eq!(written[3].path, long);
assert_eq!(read(&into, long), b"native module");
assert!(into.join("dist-package/node_modules").is_dir());
fs::remove_dir_all(&into).unwrap();
}
#[test]
fn a_single_file_archive_shaped_like_antigravitys_round_trips() {
let archive = gzip_tar(
&[Item::file("antigravity", b"one binary", 0o755)],
Dialect::Gnu,
);
let into = scratch("single");
let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap();
assert_eq!(written.len(), 1);
assert_eq!(read(&into, "antigravity"), b"one binary");
fs::remove_dir_all(&into).unwrap();
}
fn with_type_flag(items: &[Item<'_>], flag: u8) -> Vec<u8> {
let mut raw = tar(items, Dialect::Posix);
raw[156] = flag;
for byte in &mut raw[148..156] {
*byte = b' ';
}
let sum: u64 = raw[..BLOCK].iter().map(|byte| u64::from(*byte)).sum();
raw[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
gzip(&raw)
}
fn pax_block(records: &[(&str, &[u8])]) -> Vec<u8> {
let mut payload = Vec::new();
for (key, value) in records {
let without = key.len() + 1 + value.len() + 2;
let mut settled = without;
loop {
let candidate = without + settled.to_string().len();
if candidate == settled {
break;
}
settled = candidate;
}
payload.extend_from_slice(settled.to_string().as_bytes());
payload.push(b' ');
payload.extend_from_slice(key.as_bytes());
payload.push(b'=');
payload.extend_from_slice(value);
payload.push(b'\n');
}
let mut header = [0_u8; BLOCK];
header[..10].copy_from_slice(b"PaxHeaders");
header[124..136].copy_from_slice(format!("{:011o}\0", payload.len()).as_bytes());
header[100..108].copy_from_slice(b"0000644\0");
header[156] = b'x';
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
for byte in &mut header[148..156] {
*byte = b' ';
}
let sum: u64 = header.iter().map(|byte| u64::from(*byte)).sum();
header[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
let mut raw = header.to_vec();
raw.extend_from_slice(&payload);
raw.resize(raw.len() + padding_for(payload.len() as u64), 0);
raw
}
fn with_pax(records: &[(&str, &[u8])], items: &[Item<'_>]) -> Vec<u8> {
let mut raw = pax_block(records);
raw.extend_from_slice(&tar(items, Dialect::Posix));
gzip(&raw)
}
#[test]
fn a_pax_header_of_metadata_this_reader_does_not_use_is_skipped() {
let items = [Item::file("payload", b"real bytes", 0o644)];
let archive = with_pax(
&[
("SCHILY.xattr.com.apple.cs.CodeDirectory", b"\x00\x01\x02"),
("LIBARCHIVE.xattr.com.apple.cs.CodeSignature", b"\xfa\xde"),
],
&items,
);
let into = scratch("pax-skipped");
let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap();
assert_eq!(written.len(), 1);
assert_eq!(read(&into, "payload"), b"real bytes");
fs::remove_dir_all(&into).unwrap();
}
#[test]
fn a_pax_value_carrying_newlines_is_read_by_length_not_by_line() {
let items = [Item::file("payload", b"real bytes", 0o644)];
let archive = with_pax(&[("SCHILY.xattr.sig", b"one\ntwo\nthree")], &items);
let into = scratch("pax-newlines");
let written = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap();
assert_eq!(written.len(), 1);
assert_eq!(read(&into, "payload"), b"real bytes");
fs::remove_dir_all(&into).unwrap();
}
#[test]
fn a_long_name_survives_a_pax_header_standing_between_it_and_its_entry() {
let long = "a/".repeat(60) + "deep.txt";
assert!(long.len() > 100, "the fixture must force a GNU long name");
let mut raw = Vec::new();
let gnu = tar(&[Item::file(&long, b"deep bytes", 0o644)], Dialect::Gnu);
let stored = long.len() + 1;
let split = BLOCK + stored + padding_for(stored as u64);
raw.extend_from_slice(&gnu[..split]);
raw.extend_from_slice(&pax_block(&[("SCHILY.xattr.note", b"between")]));
raw.extend_from_slice(&gnu[split..]);
let into = scratch("pax-after-long");
let written = extract_gzip_tar(gzip(&raw).as_slice(), &into, ROOMY).unwrap();
assert_eq!(written.len(), 1, "{written:?}");
assert_eq!(read(&into, &long), b"deep bytes");
fs::remove_dir_all(&into).unwrap();
}
#[test]
fn a_pax_header_that_would_move_a_write_is_refused_by_the_key_it_carries() {
let items = [Item::file("payload", b"real bytes", 0o644)];
for key in ["path", "linkpath", "size", "GNU.sparse.name"] {
let archive = with_pax(&[(key, b"../escape")], &items);
let into = scratch(&format!("pax-{key}"));
let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err();
assert_eq!(error.reason(), ReasonCode::IntegrityMismatch);
assert!(
error.detail().contains(key),
"refusal for {key} does not name it: {}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
}
#[test]
fn every_entry_type_outside_file_and_directory_is_refused_by_name() {
let items = [Item::file("payload", b"x", 0o644)];
for (flag, expected) in [
(b'1', "a hard link"),
(b'2', "a symbolic link"),
(b'3', "a character device"),
(b'4', "a block device"),
(b'6', "a FIFO"),
(b'7', "a contiguous file"),
(b'K', "a GNU long link name"),
] {
let into = scratch(&format!("flag-{flag}"));
let error = extract_gzip_tar(with_type_flag(&items, flag).as_slice(), &into, ROOMY)
.unwrap_err();
assert_eq!(error.reason(), ReasonCode::IntegrityMismatch);
assert!(
error.detail().contains(expected),
"refusal for {:?} does not name it: {}",
char::from(flag),
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
}
#[test]
fn a_path_that_climbs_out_of_the_destination_is_refused() {
let archive = gzip_tar(&[Item::file("../escaped", b"x", 0o644)], Dialect::Posix);
let into = scratch("climb");
let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err();
assert!(error.detail().contains("climbs out"), "{}", error.detail());
let _ = fs::remove_dir_all(&into);
}
#[test]
fn an_absolute_path_is_refused() {
let archive = gzip_tar(&[Item::file("/etc/passwd", b"x", 0o644)], Dialect::Posix);
let into = scratch("absolute");
let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err();
assert!(
error.detail().contains("absolute path"),
"{}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn a_backslash_path_is_refused_because_it_names_a_different_file_per_system() {
let archive = gzip_tar(&[Item::file("dir\\file", b"x", 0o644)], Dialect::Posix);
let into = scratch("backslash");
let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err();
assert!(error.detail().contains("backslash"), "{}", error.detail());
let _ = fs::remove_dir_all(&into);
}
#[test]
fn bytes_that_are_not_a_gzip_member_are_refused_before_anything_is_written() {
let into = scratch("notgzip");
let error =
extract_gzip_tar(b"PK\x03\x04 this is a zip".as_slice(), &into, ROOMY).unwrap_err();
assert!(
error.detail().contains("not a gzip member"),
"{}",
error.detail()
);
assert!(!into.exists() || fs::read_dir(&into).unwrap().next().is_none());
let _ = fs::remove_dir_all(&into);
}
#[test]
fn a_corrupted_payload_fails_the_gzip_checksum() {
let mut archive = gzip_tar(
&[Item::file("payload", b"honest bytes", 0o644)],
Dialect::Posix,
);
let at = archive.len() - 8;
archive[at] ^= 0xFF;
let into = scratch("crc");
let error = extract_gzip_tar(archive.as_slice(), &into, ROOMY).unwrap_err();
assert!(
error.detail().contains("CRC-32 mismatch"),
"{}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn a_truncated_archive_is_refused_rather_than_treated_as_complete() {
let archive = gzip_tar(
&[Item::file("payload", &[7_u8; 4096], 0o644)],
Dialect::Posix,
);
let into = scratch("truncated");
let cut = archive.len() / 2;
let error = extract_gzip_tar(&archive[..cut], &into, ROOMY).unwrap_err();
assert!(
error.detail().contains("ended") || error.detail().contains("truncated"),
"{}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn an_archive_past_the_entry_limit_is_stopped() {
let bodies: Vec<String> = (0..8).map(|index| format!("file-{index}")).collect();
let items: Vec<Item<'_>> = bodies
.iter()
.map(|name| Item::file(name, b"x", 0o644))
.collect();
let into = scratch("entries");
let limits = Limits {
entries: 3,
bytes: 1 << 20,
};
let error = extract_gzip_tar(gzip_tar(&items, Dialect::Posix).as_slice(), &into, limits)
.unwrap_err();
assert!(
error.detail().contains("more than the 3 entries"),
"{}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn an_archive_that_inflates_past_the_byte_limit_is_stopped() {
let archive = gzip_tar(
&[Item::file("big", &vec![0_u8; 100_000], 0o644)],
Dialect::Posix,
);
let into = scratch("bytes");
let limits = Limits {
entries: 16,
bytes: 4096,
};
let error = extract_gzip_tar(archive.as_slice(), &into, limits).unwrap_err();
assert!(
error.detail().contains("inflates past"),
"{}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn plain_bytes_are_placed_as_an_executable() {
let into = scratch("raw");
let destination = into.join("bin/grok");
let written = place_executable(b"a whole program".as_slice(), &destination).unwrap();
assert_eq!(written, 15);
assert_eq!(fs::read(&destination).unwrap(), b"a whole program");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&destination).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755, "the placed artifact must be runnable");
}
fs::remove_dir_all(&into).unwrap();
}
fn scramble(seed: u64, bytes: &mut [u8], edits: usize) {
let mut state = seed;
for _ in 0..edits {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let octets = state.to_le_bytes();
let at = usize::from(u16::from_le_bytes([octets[2], octets[3]])) % bytes.len();
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
bytes[at] = state.to_le_bytes()[3];
}
}
#[test]
fn arbitrary_bytes_are_refused_and_never_panic() {
let good = gzip_tar(
&[
Item::directory("package"),
Item::file("package/program", &[9_u8; 3000], 0o755),
Item::file("package/notes.md", b"read me", 0o644),
],
Dialect::Gnu,
);
let into = scratch("arbitrary");
for seed in 0..256_u64 {
let mut corrupted = good.clone();
let edits = 1 + usize::try_from(seed % 12).unwrap_or(0);
scramble(seed, &mut corrupted, edits);
match extract_gzip_tar(corrupted.as_slice(), &into, ROOMY) {
Ok(_) => {}
Err(error) => assert_eq!(
error.reason(),
ReasonCode::IntegrityMismatch,
"seed {seed} blamed the wrong side: {error}"
),
}
assert!(
!into.join("..").join("escaped").exists(),
"seed {seed} wrote outside the destination"
);
}
for (label, bytes) in [
("empty", [].as_slice()),
("one byte", b"\x1f".as_slice()),
("gzip magic only", b"\x1f\x8b".as_slice()),
(
"gzip header, no body",
b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff".as_slice(),
),
("zip", b"PK\x03\x04\x14\x00\x00\x00".as_slice()),
(
"text",
b"this is not an archive, it is a sentence".as_slice(),
),
] {
let outcome = extract_gzip_tar(bytes, &into, ROOMY);
assert!(outcome.is_err(), "{label} was accepted as an archive");
}
let _ = fs::remove_dir_all(&into);
}
#[test]
fn a_declared_size_larger_than_the_archive_is_refused_not_trusted() {
let mut raw = tar(&[Item::file("payload", b"short", 0o644)], Dialect::Posix);
raw[124..136].copy_from_slice(b"77777777777\0");
for byte in &mut raw[148..156] {
*byte = b' ';
}
let sum: u64 = raw[..BLOCK].iter().map(|byte| u64::from(*byte)).sum();
raw[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
let into = scratch("bigsize");
let error = extract_gzip_tar(gzip(&raw).as_slice(), &into, ROOMY).unwrap_err();
assert_eq!(error.reason(), ReasonCode::IntegrityMismatch);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn a_header_whose_checksum_does_not_match_is_refused() {
let mut raw = tar(&[Item::file("payload", b"x", 0o644)], Dialect::Posix);
raw[0] = b'X';
let into = scratch("checksum");
let error = extract_gzip_tar(gzip(&raw).as_slice(), &into, ROOMY).unwrap_err();
assert!(
error.detail().contains("checksum mismatch"),
"{}",
error.detail()
);
let _ = fs::remove_dir_all(&into);
}
#[test]
fn a_zip_holding_both_containers_extracts() {
let room = scratch("zip-both");
let deflatable = "the same line over and over\n".repeat(400);
let bytes = zip_bytes(&[
("dist-package/", b"", true),
("dist-package/cursor-agent.cmd", b"@echo off\r\n", true),
("dist-package/index.js", deflatable.as_bytes(), false),
]);
let written =
extract_zip(io::Cursor::new(bytes), &room, ROOMY).expect("the archive extracts");
assert_eq!(written.len(), 3);
assert_eq!(
std::fs::read_to_string(room.join("dist-package/cursor-agent.cmd")).unwrap(),
"@echo off\r\n",
"a stored entry must arrive byte for byte"
);
assert_eq!(
std::fs::read_to_string(room.join("dist-package/index.js")).unwrap(),
deflatable,
"a deflated entry must inflate to what went in"
);
assert!(room.join("dist-package").is_dir());
}
#[test]
fn a_zip_entry_whose_bytes_moved_is_refused() {
let room = scratch("zip-crc");
let mut bytes = zip_bytes(&[("payload.txt", b"the original bytes", true)]);
let at = bytes
.windows(18)
.position(|window| window == b"the original bytes")
.expect("the stored body is findable");
bytes[at] = b'T';
let error = extract_zip(io::Cursor::new(bytes), &room, ROOMY)
.expect_err("a changed entry must be refused");
assert!(
format!("{error}").contains("CRC-32"),
"the refusal should name what disagreed: {error}"
);
}
#[test]
fn a_zip_entry_that_climbs_out_is_refused() {
let room = scratch("zip-climb");
let bytes = zip_bytes(&[("../escaped.txt", b"nope", true)]);
let error = extract_zip(io::Cursor::new(bytes), &room, ROOMY)
.expect_err("a climbing path must be refused");
assert!(
format!("{error}").contains("climbs out"),
"unexpected refusal: {error}"
);
assert!(
!room
.parent()
.is_some_and(|up| up.join("escaped.txt").exists())
);
}
#[test]
fn a_zip_using_another_compression_method_is_refused_by_name() {
let room = scratch("zip-method");
let mut bytes = zip_bytes(&[("payload.txt", b"body", true)]);
let at = bytes
.windows(4)
.rposition(|window| window == [0x50, 0x4B, 0x01, 0x02])
.expect("a central header exists");
bytes[at + 10] = 93;
bytes[at + 11] = 0;
let error = extract_zip(io::Cursor::new(bytes), &room, ROOMY)
.expect_err("an unimplemented method must be refused");
assert!(
format!("{error}").contains("method 93"),
"the refusal should name the method: {error}"
);
}
#[test]
fn a_zip_entry_with_a_data_descriptor_is_refused() {
let room = scratch("zip-descriptor");
let mut bytes = zip_bytes(&[("payload.txt", b"body", true)]);
let at = bytes
.windows(4)
.rposition(|window| window == [0x50, 0x4B, 0x01, 0x02])
.expect("a central header exists");
bytes[at + 8] |= 0b0000_1000;
let error = extract_zip(io::Cursor::new(bytes), &room, ROOMY)
.expect_err("a data descriptor must be refused");
assert!(
format!("{error}").contains("data descriptor"),
"unexpected refusal: {error}"
);
}
#[test]
fn a_central_directory_that_declares_itself_enormous_is_refused_before_allocating() {
let room = scratch("zip-central");
let mut bytes = zip_bytes(&[("payload.txt", b"small", false)]);
let eocd = bytes.len() - 22;
let declared: u32 = 16 * 1024 * 1024 + 1;
bytes[eocd + 12..eocd + 16].copy_from_slice(&declared.to_le_bytes());
let error = extract_zip(io::Cursor::new(bytes), &room, ROOMY)
.expect_err("a central directory larger than the reader will hold must be refused");
assert!(
format!("{error}").contains("implausibly large"),
"unexpected refusal: {error}"
);
assert!(
!room.join("payload.txt").exists(),
"nothing should have been written"
);
let _ = fs::remove_dir_all(&room);
}
#[test]
fn a_zip_that_inflates_past_the_limit_is_refused_before_it_writes() {
let room = scratch("zip-limit");
let big = "x".repeat(4096);
let bytes = zip_bytes(&[("payload.txt", big.as_bytes(), false)]);
let tight = Limits {
entries: 4096,
bytes: 1024,
};
let error = extract_zip(io::Cursor::new(bytes), &room, tight)
.expect_err("an over-large archive must be refused");
assert!(
format!("{error}").contains("inflates past"),
"unexpected refusal: {error}"
);
assert!(
!room.join("payload.txt").exists(),
"nothing should have been written: the central directory states the \
sizes, so the answer is knowable before the first byte lands"
);
}
}