use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::format::{ExtractError, FormatError};
use crate::metadata::validate_file_path_bytes;
const TAR_BLOCK_LEN: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TarEntryKind {
Regular,
Directory,
Symlink,
Hardlink,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetadataDiagnostic {
pub profile: &'static str,
pub message: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedTarMember {
pub path: Vec<u8>,
pub kind: TarEntryKind,
pub data: Vec<u8>,
pub link_target: Option<Vec<u8>>,
pub mode: u32,
pub mtime: u64,
pub logical_size: u64,
pub diagnostics: Vec<MetadataDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTarMember<'a> {
pub path: Vec<u8>,
pub kind: TarEntryKind,
pub data: &'a [u8],
pub link_target: Option<Vec<u8>>,
pub mode: u32,
pub mtime: u64,
pub logical_size: u64,
pub diagnostics: Vec<MetadataDiagnostic>,
}
impl ParsedTarMember<'_> {
pub fn to_owned_member(&self) -> OwnedTarMember {
OwnedTarMember {
path: self.path.clone(),
kind: self.kind,
data: self.data.to_vec(),
link_target: self.link_target.clone(),
mode: self.mode,
mtime: self.mtime,
logical_size: self.logical_size,
diagnostics: self.diagnostics.clone(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SafeExtractionOptions {
pub overwrite_existing: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StreamedTarMemberMetadata {
pub path: Vec<u8>,
pub kind: TarEntryKind,
pub link_target: Option<Vec<u8>>,
pub mode: u32,
pub mtime: u64,
pub logical_size: u64,
pub diagnostics: Vec<MetadataDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TarStreamMemberSummary {
pub path: Vec<u8>,
pub kind: TarEntryKind,
pub link_target: Option<Vec<u8>>,
pub mode: u32,
pub mtime: u64,
pub logical_size: u64,
pub diagnostics: Vec<MetadataDiagnostic>,
pub group_start: u64,
pub group_size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TarStreamSummary {
pub members: Vec<TarStreamMemberSummary>,
pub tar_total_size: u64,
pub total_extraction_size: u64,
}
pub(crate) trait TarMemberGroupReader {
fn read_some_member_bytes(&mut self, buf: &mut [u8]) -> Result<usize, ExtractError>;
fn read_exact_member_bytes(&mut self, mut buf: &mut [u8]) -> Result<(), ExtractError> {
while !buf.is_empty() {
let read = self.read_some_member_bytes(buf)?;
if read == 0 {
return Err(
FormatError::InvalidArchive("tar member group exceeds frame range").into(),
);
}
let (_, rest) = buf.split_at_mut(read);
buf = rest;
}
Ok(())
}
}
trait TarMemberStreamHandler {
fn on_member(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), ExtractError>;
fn write_regular_payload(&mut self, bytes: &[u8]) -> Result<(), ExtractError>;
}
pub(crate) trait TarStreamObserver {
fn on_member_start(&mut self, _member: &StreamedTarMemberMetadata) -> Result<(), FormatError> {
Ok(())
}
fn on_regular_payload(&mut self, _bytes: &[u8]) -> Result<(), FormatError> {
Ok(())
}
fn on_member_complete(
&mut self,
member: &StreamedTarMemberMetadata,
) -> Result<Vec<MetadataDiagnostic>, FormatError> {
Ok(member.diagnostics.clone())
}
}
pub(crate) struct NoopTarStreamObserver;
impl TarStreamObserver for NoopTarStreamObserver {}
pub(crate) struct TarStreamFilesystemRestoreObserver<'a> {
handler: FilesystemRestoreHandler<'a>,
}
impl<'a> TarStreamFilesystemRestoreObserver<'a> {
pub(crate) fn new(root: &'a Path, options: SafeExtractionOptions) -> Self {
Self {
handler: FilesystemRestoreHandler::new(root, options),
}
}
}
impl TarStreamObserver for TarStreamFilesystemRestoreObserver<'_> {
fn on_member_start(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), FormatError> {
self.handler
.on_member(member)
.map_err(format_error_from_extract_error)
}
fn on_regular_payload(&mut self, bytes: &[u8]) -> Result<(), FormatError> {
self.handler
.write_regular_payload(bytes)
.map_err(format_error_from_extract_error)
}
fn on_member_complete(
&mut self,
member: &StreamedTarMemberMetadata,
) -> Result<Vec<MetadataDiagnostic>, FormatError> {
self.handler
.finish(member)
.map_err(format_error_from_extract_error)
}
}
#[derive(Default)]
struct LocalMetadata {
pax_path: Option<Vec<u8>>,
pax_linkpath: Option<Vec<u8>>,
pax_size: Option<u64>,
gnu_long_name: Option<Vec<u8>>,
gnu_long_link: Option<Vec<u8>>,
diagnostics: Vec<MetadataDiagnostic>,
}
pub fn parse_tar_member_group<'a>(
group: &'a [u8],
max_path_length: u32,
) -> Result<ParsedTarMember<'a>, FormatError> {
if group.len() < TAR_BLOCK_LEN || group.len() % TAR_BLOCK_LEN != 0 {
return Err(FormatError::InvalidArchive(
"tar member group is not block aligned",
));
}
let mut cursor = 0usize;
let mut metadata = LocalMetadata::default();
loop {
let header = slice(group, cursor, TAR_BLOCK_LEN)?;
if header.iter().all(|byte| *byte == 0) {
return Err(FormatError::InvalidArchive("tar member header is empty"));
}
verify_tar_checksum(header)?;
let typeflag = header[156];
let header_size = parse_tar_octal(&header[124..136])?;
let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
let effective_size = if is_main {
metadata.pax_size.unwrap_or(header_size)
} else {
header_size
};
let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
let payload_len = to_usize(effective_size)?;
let payload_end = checked_add(payload_start, payload_len)?;
let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
let payload = slice(group, payload_start, payload_len)?;
if padded_end > group.len() {
return Err(FormatError::InvalidArchive(
"tar member payload exceeds group",
));
}
if group[payload_end..padded_end].iter().any(|byte| *byte != 0) {
return Err(FormatError::InvalidArchive(
"tar member padding is non-zero",
));
}
match typeflag {
b'x' => {
parse_pax_records(payload, &mut metadata)?;
cursor = padded_end;
}
b'g' => {
return Err(FormatError::InvalidArchive(
"global PAX headers are not allowed",
));
}
b'V' | b'M' | b'N' => {
return Err(FormatError::InvalidArchive(
"global GNU headers are not allowed",
));
}
b'L' => {
metadata.gnu_long_name = Some(trimmed_metadata_payload(payload));
cursor = padded_end;
}
b'K' => {
metadata.gnu_long_link = Some(trimmed_metadata_payload(payload));
cursor = padded_end;
}
b'S' => {
return Err(FormatError::ReaderUnsupported(
"unsupported GNU sparse tar entry",
));
}
0 | b'0' | b'5' | b'2' | b'1' => {
if padded_end != group.len() {
return Err(FormatError::InvalidArchive(
"tar member group has bytes after main entry",
));
}
let kind = match typeflag {
b'5' => TarEntryKind::Directory,
b'2' => TarEntryKind::Symlink,
b'1' => TarEntryKind::Hardlink,
_ => TarEntryKind::Regular,
};
let mode = parse_tar_octal(&header[100..108])? as u32;
let mtime = parse_tar_octal(&header[136..148])?;
let path = canonical_main_path(header, kind, &metadata, max_path_length)?;
let link_target =
canonical_link_target(header, kind, &path, &metadata, max_path_length)?;
if kind != TarEntryKind::Regular && effective_size != 0 {
return Err(FormatError::InvalidArchive(
"non-regular tar entry has non-zero payload size",
));
}
let logical_size = if kind == TarEntryKind::Regular {
effective_size
} else {
0
};
return Ok(ParsedTarMember {
path,
kind,
data: if kind == TarEntryKind::Regular {
payload
} else {
&[]
},
mode,
mtime,
link_target,
logical_size,
diagnostics: metadata.diagnostics,
});
}
_ => {
return Err(FormatError::ReaderUnsupported("unsupported tar entry type"));
}
}
if cursor >= group.len() {
return Err(FormatError::InvalidArchive(
"tar member group has metadata records but no main entry",
));
}
}
}
pub fn validate_tar_stream_total_extraction_size(
stream: &[u8],
max_path_length: u32,
cap: u64,
) -> Result<(), FormatError> {
if stream.len() % TAR_BLOCK_LEN != 0 {
return Err(FormatError::InvalidArchive(
"tar stream is not block aligned",
));
}
let mut cursor = 0usize;
let mut total = 0u64;
while cursor < stream.len() {
let group_end = tar_member_group_end(stream, cursor)?;
let member = parse_tar_member_group(&stream[cursor..group_end], max_path_length)?;
if member.kind == TarEntryKind::Regular {
total = total
.checked_add(member.logical_size)
.ok_or(FormatError::InvalidArchive(
"total extraction size overflow",
))?;
if total > cap {
return Err(FormatError::ReaderUnsupported(
"total extraction size exceeds configured cap",
));
}
}
cursor = group_end;
}
Ok(())
}
pub(crate) struct TarStreamTotalExtractionSizeValidator {
cursor: usize,
total: u64,
max_path_length: u32,
cap: u64,
}
impl TarStreamTotalExtractionSizeValidator {
pub(crate) fn new(max_path_length: u32, cap: u64) -> Self {
Self {
cursor: 0,
total: 0,
max_path_length,
cap,
}
}
pub(crate) fn observe(&mut self, stream: &[u8]) -> Result<(), FormatError> {
while self.cursor < stream.len() {
let Some(group_end) = try_tar_member_group_end(stream, self.cursor)? else {
return Ok(());
};
let member =
parse_tar_member_group(&stream[self.cursor..group_end], self.max_path_length)?;
if member.kind == TarEntryKind::Regular {
self.total = self.total.checked_add(member.logical_size).ok_or(
FormatError::InvalidArchive("total extraction size overflow"),
)?;
if self.total > self.cap {
return Err(FormatError::ReaderUnsupported(
"total extraction size exceeds configured cap",
));
}
}
self.cursor = group_end;
}
Ok(())
}
}
pub(crate) struct TarStreamSummaryValidator<O = NoopTarStreamObserver> {
state: StreamingTarState,
max_path_length: u32,
total_extraction_size: u64,
extraction_cap: u64,
max_metadata_payload_bytes: usize,
max_member_count: u64,
members: Vec<TarStreamMemberSummary>,
observer: O,
}
impl<O: TarStreamObserver> TarStreamSummaryValidator<O> {
pub(crate) fn with_observer(
max_path_length: u32,
extraction_cap: u64,
max_metadata_payload_bytes: usize,
max_member_count: u64,
observer: O,
) -> Self {
Self {
state: StreamingTarState::new_member(0),
max_path_length,
total_extraction_size: 0,
extraction_cap,
max_metadata_payload_bytes,
max_member_count,
members: Vec::new(),
observer,
}
}
pub(crate) fn observe(&mut self, mut input: &[u8]) -> Result<(), FormatError> {
while !input.is_empty() {
let state = std::mem::replace(&mut self.state, StreamingTarState::new_member(0));
let (consumed, next) = self.consume_state(state, input)?;
self.state = self.resolve_ready_state(next)?;
input = &input[consumed..];
}
Ok(())
}
fn consume_state(
&mut self,
state: StreamingTarState,
input: &[u8],
) -> Result<(usize, StreamingTarState), FormatError> {
match state {
StreamingTarState::Header {
metadata,
group_start,
mut group_size,
mut header,
} => {
let needed = TAR_BLOCK_LEN - header.len();
let take = needed.min(input.len());
header.extend_from_slice(&input[..take]);
group_size = checked_u64_add(group_size, take as u64)?;
checked_u64_add(group_start, group_size)?;
let next = if header.len() == TAR_BLOCK_LEN {
let mut header_bytes = [0u8; TAR_BLOCK_LEN];
header_bytes.copy_from_slice(&header);
self.state_after_header(metadata, group_start, group_size, header_bytes)?
} else {
StreamingTarState::Header {
metadata,
group_start,
group_size,
header,
}
};
Ok((take, next))
}
StreamingTarState::Payload {
metadata,
group_start,
mut group_size,
mut entry,
mut remaining,
padding_remaining,
} => {
let take = remaining.min(input.len() as u64) as usize;
if let Some(payload) = entry.metadata_payload_mut() {
let next_len = checked_add(payload.len(), take)?;
if next_len > self.max_metadata_payload_bytes {
return Err(FormatError::ReaderUnsupported(
"tar metadata payload exceeds configured streaming cap",
));
}
payload.extend_from_slice(&input[..take]);
} else if take > 0 && entry.is_regular_main() {
self.observer.on_regular_payload(&input[..take])?;
}
remaining -= take as u64;
group_size = checked_u64_add(group_size, take as u64)?;
checked_u64_add(group_start, group_size)?;
let next = if remaining == 0 {
StreamingTarState::Padding {
metadata,
group_start,
group_size,
entry,
remaining: padding_remaining,
}
} else {
StreamingTarState::Payload {
metadata,
group_start,
group_size,
entry,
remaining,
padding_remaining,
}
};
Ok((take, next))
}
StreamingTarState::Padding {
metadata,
group_start,
mut group_size,
entry,
mut remaining,
} => {
let take = remaining.min(input.len() as u64) as usize;
if input[..take].iter().any(|byte| *byte != 0) {
return Err(FormatError::InvalidArchive(
"tar member padding is non-zero",
));
}
remaining -= take as u64;
group_size = checked_u64_add(group_size, take as u64)?;
checked_u64_add(group_start, group_size)?;
let next = if remaining == 0 {
self.finish_entry_parts(metadata, group_start, group_size, entry)?
} else {
StreamingTarState::Padding {
metadata,
group_start,
group_size,
entry,
remaining,
}
};
Ok((take, next))
}
}
}
fn resolve_ready_state(
&mut self,
mut state: StreamingTarState,
) -> Result<StreamingTarState, FormatError> {
loop {
state = match state {
StreamingTarState::Payload {
metadata,
group_start,
group_size,
entry,
remaining: 0,
padding_remaining,
} => StreamingTarState::Padding {
metadata,
group_start,
group_size,
entry,
remaining: padding_remaining,
},
StreamingTarState::Padding {
metadata,
group_start,
group_size,
entry,
remaining: 0,
} => self.finish_entry_parts(metadata, group_start, group_size, entry)?,
other => return Ok(other),
};
}
}
pub(crate) fn tar_total_size(&self) -> u64 {
match &self.state {
StreamingTarState::Header {
group_start,
group_size,
..
}
| StreamingTarState::Payload {
group_start,
group_size,
..
}
| StreamingTarState::Padding {
group_start,
group_size,
..
} => group_start + group_size,
}
}
pub(crate) fn finish(self) -> Result<TarStreamSummary, FormatError> {
let tar_total_size = self.tar_total_size();
match self.state {
StreamingTarState::Header {
header, group_size, ..
} if header.is_empty() && group_size == 0 => Ok(TarStreamSummary {
members: self.members,
tar_total_size,
total_extraction_size: self.total_extraction_size,
}),
_ => Err(FormatError::InvalidArchive(
"tar stream ended inside member group",
)),
}
}
fn state_after_header(
&mut self,
mut metadata: LocalMetadata,
group_start: u64,
group_size: u64,
header: [u8; TAR_BLOCK_LEN],
) -> Result<StreamingTarState, FormatError> {
if header.iter().all(|byte| *byte == 0) {
return Err(FormatError::InvalidArchive("tar member header is empty"));
}
verify_tar_checksum(&header)?;
let typeflag = header[156];
let header_size = parse_tar_octal(&header[124..136])?;
let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
let effective_size = if is_main {
metadata.pax_size.unwrap_or(header_size)
} else {
header_size
};
let padding_remaining = padding_to_512_u64(effective_size);
let entry = match typeflag {
b'x' => PendingTarEntry::LocalPax {
payload: Vec::new(),
},
b'L' => PendingTarEntry::GnuLongName {
payload: Vec::new(),
},
b'K' => PendingTarEntry::GnuLongLink {
payload: Vec::new(),
},
b'g' => {
return Err(FormatError::InvalidArchive(
"global PAX headers are not allowed",
))
}
b'V' | b'M' | b'N' => {
return Err(FormatError::InvalidArchive(
"global GNU headers are not allowed",
))
}
b'S' => {
return Err(FormatError::ReaderUnsupported(
"unsupported GNU sparse tar entry",
))
}
0 | b'0' | b'5' | b'2' | b'1' => {
let kind = match typeflag {
b'5' => TarEntryKind::Directory,
b'2' => TarEntryKind::Symlink,
b'1' => TarEntryKind::Hardlink,
_ => TarEntryKind::Regular,
};
let mode = parse_tar_octal(&header[100..108])? as u32;
let mtime = parse_tar_octal(&header[136..148])?;
let path = canonical_main_path(&header, kind, &metadata, self.max_path_length)?;
let link_target =
canonical_link_target(&header, kind, &path, &metadata, self.max_path_length)?;
if kind != TarEntryKind::Regular && effective_size != 0 {
return Err(FormatError::InvalidArchive(
"non-regular tar entry has non-zero payload size",
));
}
let logical_size = if kind == TarEntryKind::Regular {
effective_size
} else {
0
};
if kind == TarEntryKind::Regular {
self.total_extraction_size =
self.total_extraction_size.checked_add(logical_size).ok_or(
FormatError::InvalidArchive("total extraction size overflow"),
)?;
if self.total_extraction_size > self.extraction_cap {
return Err(FormatError::ReaderUnsupported(
"total extraction size exceeds configured cap",
));
}
}
let member = StreamedTarMemberMetadata {
path,
kind,
link_target,
mode,
mtime,
logical_size,
diagnostics: std::mem::take(&mut metadata.diagnostics),
};
self.observer.on_member_start(&member)?;
PendingTarEntry::Main {
member,
group_start,
}
}
_ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
};
self.resolve_ready_state(StreamingTarState::Payload {
metadata,
group_start,
group_size,
entry,
remaining: effective_size,
padding_remaining,
})
}
fn finish_entry_parts(
&mut self,
mut metadata: LocalMetadata,
group_start: u64,
group_size: u64,
entry: PendingTarEntry,
) -> Result<StreamingTarState, FormatError> {
match entry {
PendingTarEntry::LocalPax { payload } => {
parse_pax_records(&payload, &mut metadata)?;
Ok(StreamingTarState::Header {
metadata,
group_start,
group_size,
header: Vec::new(),
})
}
PendingTarEntry::GnuLongName { payload } => {
metadata.gnu_long_name = Some(trimmed_metadata_payload(&payload));
Ok(StreamingTarState::Header {
metadata,
group_start,
group_size,
header: Vec::new(),
})
}
PendingTarEntry::GnuLongLink { payload } => {
metadata.gnu_long_link = Some(trimmed_metadata_payload(&payload));
Ok(StreamingTarState::Header {
metadata,
group_start,
group_size,
header: Vec::new(),
})
}
PendingTarEntry::Main {
member,
group_start,
} => {
if self.members.len() as u64 >= self.max_member_count {
return Err(FormatError::ReaderUnsupported(
"tar member count exceeds configured streaming cap",
));
}
let diagnostics = self.observer.on_member_complete(&member)?;
self.members.push(TarStreamMemberSummary {
path: member.path,
kind: member.kind,
link_target: member.link_target,
mode: member.mode,
mtime: member.mtime,
logical_size: member.logical_size,
diagnostics,
group_start,
group_size,
});
Ok(StreamingTarState::new_member(checked_u64_add(
group_start,
group_size,
)?))
}
}
}
}
enum StreamingTarState {
Header {
metadata: LocalMetadata,
group_start: u64,
group_size: u64,
header: Vec<u8>,
},
Payload {
metadata: LocalMetadata,
group_start: u64,
group_size: u64,
entry: PendingTarEntry,
remaining: u64,
padding_remaining: u64,
},
Padding {
metadata: LocalMetadata,
group_start: u64,
group_size: u64,
entry: PendingTarEntry,
remaining: u64,
},
}
impl StreamingTarState {
fn new_member(group_start: u64) -> Self {
Self::Header {
metadata: LocalMetadata::default(),
group_start,
group_size: 0,
header: Vec::new(),
}
}
}
enum PendingTarEntry {
LocalPax {
payload: Vec<u8>,
},
GnuLongName {
payload: Vec<u8>,
},
GnuLongLink {
payload: Vec<u8>,
},
Main {
member: StreamedTarMemberMetadata,
group_start: u64,
},
}
impl PendingTarEntry {
fn metadata_payload_mut(&mut self) -> Option<&mut Vec<u8>> {
match self {
Self::LocalPax { payload }
| Self::GnuLongName { payload }
| Self::GnuLongLink { payload } => Some(payload),
Self::Main { .. } => None,
}
}
fn is_regular_main(&self) -> bool {
matches!(
self,
Self::Main {
member: StreamedTarMemberMetadata {
kind: TarEntryKind::Regular,
..
},
..
}
)
}
}
fn checked_u64_add(lhs: u64, rhs: u64) -> Result<u64, FormatError> {
lhs.checked_add(rhs).ok_or(FormatError::InvalidArchive(
"tar member arithmetic overflow",
))
}
pub(crate) fn try_tar_member_group_end(
stream: &[u8],
start: usize,
) -> Result<Option<usize>, FormatError> {
let mut cursor = start;
let mut metadata = LocalMetadata::default();
loop {
let Some(header) = try_slice(stream, cursor, TAR_BLOCK_LEN)? else {
return Ok(None);
};
if header.iter().all(|byte| *byte == 0) {
return Err(FormatError::InvalidArchive("tar member header is empty"));
}
verify_tar_checksum(header)?;
let typeflag = header[156];
let header_size = parse_tar_octal(&header[124..136])?;
let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
let effective_size = if is_main {
metadata.pax_size.unwrap_or(header_size)
} else {
header_size
};
let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
let payload_len = to_usize(effective_size)?;
let payload_end = checked_add(payload_start, payload_len)?;
let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
let Some(payload) = try_slice(stream, payload_start, payload_len)? else {
return Ok(None);
};
if padded_end > stream.len() {
return Ok(None);
}
if stream[payload_end..padded_end]
.iter()
.any(|byte| *byte != 0)
{
return Err(FormatError::InvalidArchive(
"tar member padding is non-zero",
));
}
match typeflag {
b'x' => {
parse_pax_records(payload, &mut metadata)?;
cursor = padded_end;
}
b'L' | b'K' => {
cursor = padded_end;
}
b'g' => {
return Err(FormatError::InvalidArchive(
"global PAX headers are not allowed",
));
}
b'V' | b'M' | b'N' => {
return Err(FormatError::InvalidArchive(
"global GNU headers are not allowed",
));
}
b'S' => {
return Err(FormatError::ReaderUnsupported(
"unsupported GNU sparse tar entry",
));
}
0 | b'0' | b'5' | b'2' | b'1' => return Ok(Some(padded_end)),
_ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
}
if cursor >= stream.len() {
return Ok(None);
}
}
}
fn try_slice(stream: &[u8], offset: usize, len: usize) -> Result<Option<&[u8]>, FormatError> {
let end = checked_add(offset, len)?;
if end > stream.len() {
return Ok(None);
}
Ok(Some(&stream[offset..end]))
}
pub(crate) fn stream_regular_tar_member_group_to_writer<R, W>(
reader: &mut R,
expected_path: &[u8],
expected_file_data_size: u64,
group_len: u64,
max_path_length: u32,
writer: &mut W,
) -> Result<Vec<MetadataDiagnostic>, ExtractError>
where
R: TarMemberGroupReader,
W: Write,
{
let mut handler = RegularWriterHandler { writer };
let member = stream_tar_member_group(
reader,
expected_path,
expected_file_data_size,
group_len,
max_path_length,
&mut handler,
)?;
Ok(member.diagnostics)
}
pub(crate) fn restore_streaming_tar_member_group<R>(
root: &Path,
expected_path: &[u8],
expected_file_data_size: u64,
group_len: u64,
max_path_length: u32,
options: SafeExtractionOptions,
reader: &mut R,
) -> Result<Vec<MetadataDiagnostic>, ExtractError>
where
R: TarMemberGroupReader,
{
let mut handler = FilesystemRestoreHandler::new(root, options);
let member = stream_tar_member_group(
reader,
expected_path,
expected_file_data_size,
group_len,
max_path_length,
&mut handler,
)?;
handler.finish(&member)
}
fn stream_tar_member_group<R, H>(
reader: &mut R,
expected_path: &[u8],
expected_file_data_size: u64,
group_len: u64,
max_path_length: u32,
handler: &mut H,
) -> Result<StreamedTarMemberMetadata, ExtractError>
where
R: TarMemberGroupReader,
H: TarMemberStreamHandler,
{
if group_len < TAR_BLOCK_LEN as u64 || group_len % TAR_BLOCK_LEN as u64 != 0 {
return Err(FormatError::InvalidArchive("tar member group is not block aligned").into());
}
let mut remaining = group_len;
let mut metadata = LocalMetadata::default();
loop {
let mut header = [0u8; TAR_BLOCK_LEN];
read_member_bytes(reader, &mut header, &mut remaining)?;
if header.iter().all(|byte| *byte == 0) {
return Err(FormatError::InvalidArchive("tar member header is empty").into());
}
verify_tar_checksum(&header)?;
let typeflag = header[156];
let header_size = parse_tar_octal(&header[124..136])?;
let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
let effective_size = if is_main {
metadata.pax_size.unwrap_or(header_size)
} else {
header_size
};
let padding_len = padding_to_512_u64(effective_size);
let entry_payload_len =
effective_size
.checked_add(padding_len)
.ok_or(FormatError::InvalidArchive(
"tar member arithmetic overflow",
))?;
if entry_payload_len > remaining {
return Err(FormatError::InvalidArchive("tar member payload exceeds group").into());
}
match typeflag {
b'x' => {
let payload = read_member_vec(reader, effective_size, &mut remaining)?;
parse_pax_records(&payload, &mut metadata)?;
read_zero_padding(reader, padding_len, &mut remaining)?;
}
b'g' => {
return Err(
FormatError::InvalidArchive("global PAX headers are not allowed").into(),
);
}
b'V' | b'M' | b'N' => {
return Err(
FormatError::InvalidArchive("global GNU headers are not allowed").into(),
);
}
b'L' => {
let payload = read_member_vec(reader, effective_size, &mut remaining)?;
metadata.gnu_long_name = Some(trimmed_metadata_payload(&payload));
read_zero_padding(reader, padding_len, &mut remaining)?;
}
b'K' => {
let payload = read_member_vec(reader, effective_size, &mut remaining)?;
metadata.gnu_long_link = Some(trimmed_metadata_payload(&payload));
read_zero_padding(reader, padding_len, &mut remaining)?;
}
b'S' => {
return Err(
FormatError::ReaderUnsupported("unsupported GNU sparse tar entry").into(),
);
}
0 | b'0' | b'5' | b'2' | b'1' => {
let kind = match typeflag {
b'5' => TarEntryKind::Directory,
b'2' => TarEntryKind::Symlink,
b'1' => TarEntryKind::Hardlink,
_ => TarEntryKind::Regular,
};
let mode = parse_tar_octal(&header[100..108])? as u32;
let mtime = parse_tar_octal(&header[136..148])?;
let path = canonical_main_path(&header, kind, &metadata, max_path_length)?;
let link_target =
canonical_link_target(&header, kind, &path, &metadata, max_path_length)?;
if kind != TarEntryKind::Regular && effective_size != 0 {
return Err(FormatError::InvalidArchive(
"non-regular tar entry has non-zero payload size",
)
.into());
}
let logical_size = if kind == TarEntryKind::Regular {
effective_size
} else {
0
};
let member = StreamedTarMemberMetadata {
path,
kind,
link_target,
mode,
mtime,
logical_size,
diagnostics: std::mem::take(&mut metadata.diagnostics),
};
if member.path != expected_path {
return Err(FormatError::InvalidArchive(
"tar member path does not match FileEntry path",
)
.into());
}
if member.logical_size != expected_file_data_size {
return Err(FormatError::InvalidArchive(
"tar member size does not match FileEntry file_data_size",
)
.into());
}
handler.on_member(&member)?;
if member.kind == TarEntryKind::Regular {
stream_regular_payload(reader, effective_size, &mut remaining, handler)?;
}
read_zero_padding(reader, padding_len, &mut remaining)?;
if remaining != 0 {
return Err(FormatError::InvalidArchive(
"tar member group has bytes after main entry",
)
.into());
}
return Ok(member);
}
_ => {
return Err(FormatError::ReaderUnsupported("unsupported tar entry type").into());
}
}
if remaining == 0 {
return Err(FormatError::InvalidArchive(
"tar member group has metadata records but no main entry",
)
.into());
}
}
}
struct RegularWriterHandler<'a, W> {
writer: &'a mut W,
}
impl<W: Write> TarMemberStreamHandler for RegularWriterHandler<'_, W> {
fn on_member(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), ExtractError> {
if member.kind != TarEntryKind::Regular {
return Err(FormatError::ReaderUnsupported(
"extract_file_to_writer returns only regular file payloads",
)
.into());
}
Ok(())
}
fn write_regular_payload(&mut self, bytes: &[u8]) -> Result<(), ExtractError> {
self.writer.write_all(bytes).map_err(ExtractError::Output)
}
}
struct FilesystemRestoreHandler<'a> {
root: &'a Path,
options: SafeExtractionOptions,
destination: Option<PathBuf>,
temp_path: Option<PathBuf>,
file: Option<fs::File>,
}
impl<'a> FilesystemRestoreHandler<'a> {
fn new(root: &'a Path, options: SafeExtractionOptions) -> Self {
Self {
root,
options,
destination: None,
temp_path: None,
file: None,
}
}
fn finish(
&mut self,
member: &StreamedTarMemberMetadata,
) -> Result<Vec<MetadataDiagnostic>, ExtractError> {
let mut diagnostics = member.diagnostics.clone();
if member.kind != TarEntryKind::Regular {
return Ok(diagnostics);
}
let mut file = self.file.take().ok_or(FormatError::InvalidArchive(
"regular file output is missing",
))?;
file.flush()
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))?;
apply_restored_regular_file_metadata_parts(
&file,
member.mode,
member.mtime,
&mut diagnostics,
);
drop(file);
let destination = self.destination.take().ok_or(FormatError::InvalidArchive(
"regular file destination is missing",
))?;
let temp_path = self.temp_path.take().ok_or(FormatError::InvalidArchive(
"regular file temp path is missing",
))?;
if self.options.overwrite_existing && destination.exists() {
fs::remove_file(&destination).map_err(|_| {
FormatError::FilesystemExtractionFailed("failed to remove old file")
})?;
}
fs::rename(&temp_path, &destination).map_err(|_| {
FormatError::FilesystemExtractionFailed("failed to create regular file")
})?;
Ok(diagnostics)
}
}
impl Drop for FilesystemRestoreHandler<'_> {
fn drop(&mut self) {
if let Some(path) = self.temp_path.take() {
let _ = fs::remove_file(path);
}
}
}
impl TarMemberStreamHandler for FilesystemRestoreHandler<'_> {
fn on_member(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), ExtractError> {
let destination = prepare_destination(self.root, &member.path, member.kind, self.options)?;
match member.kind {
TarEntryKind::Regular => {
let (temp_path, file) = create_temp_regular_file(&destination)?;
self.destination = Some(destination);
self.temp_path = Some(temp_path);
self.file = Some(file);
}
TarEntryKind::Directory => create_directory(&destination)?,
TarEntryKind::Symlink => {
let target = member
.link_target
.as_deref()
.ok_or(FormatError::InvalidArchive("symlink target is missing"))?;
validate_symlink_target(&member.path, target)?;
create_symlink(&destination, target, self.options)?;
}
TarEntryKind::Hardlink => {
let target = member
.link_target
.as_deref()
.ok_or(FormatError::InvalidArchive("hardlink target is missing"))?;
let target_path = existing_safe_regular_path(self.root, target)?;
create_hardlink(&destination, &target_path, self.options)?;
}
}
Ok(())
}
fn write_regular_payload(&mut self, bytes: &[u8]) -> Result<(), ExtractError> {
let file = self.file.as_mut().ok_or(FormatError::InvalidArchive(
"regular file output is missing",
))?;
file.write_all(bytes)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))?;
Ok(())
}
}
fn format_error_from_extract_error(error: ExtractError) -> FormatError {
match error {
ExtractError::Format(error) => error,
ExtractError::Output(_) => {
FormatError::FilesystemExtractionFailed("failed to write regular file")
}
}
}
fn read_member_bytes<R: TarMemberGroupReader>(
reader: &mut R,
buf: &mut [u8],
remaining: &mut u64,
) -> Result<(), ExtractError> {
if buf.len() as u64 > *remaining {
return Err(FormatError::InvalidArchive("tar member payload exceeds group").into());
}
reader.read_exact_member_bytes(buf)?;
*remaining -= buf.len() as u64;
Ok(())
}
fn read_member_vec<R: TarMemberGroupReader>(
reader: &mut R,
len: u64,
remaining: &mut u64,
) -> Result<Vec<u8>, ExtractError> {
let mut out = vec![0u8; to_usize(len)?];
read_member_bytes(reader, &mut out, remaining)?;
Ok(out)
}
fn read_zero_padding<R: TarMemberGroupReader>(
reader: &mut R,
len: u64,
remaining: &mut u64,
) -> Result<(), ExtractError> {
let mut pending = len;
let mut buf = [0u8; 8192];
while pending > 0 {
let chunk_len = pending.min(buf.len() as u64) as usize;
read_member_bytes(reader, &mut buf[..chunk_len], remaining)?;
if buf[..chunk_len].iter().any(|byte| *byte != 0) {
return Err(FormatError::InvalidArchive("tar member padding is non-zero").into());
}
pending -= chunk_len as u64;
}
Ok(())
}
fn stream_regular_payload<R, H>(
reader: &mut R,
len: u64,
remaining: &mut u64,
handler: &mut H,
) -> Result<(), ExtractError>
where
R: TarMemberGroupReader,
H: TarMemberStreamHandler,
{
let mut pending = len;
let mut buf = [0u8; 64 * 1024];
while pending > 0 {
let chunk_len = pending.min(buf.len() as u64).min(*remaining) as usize;
let read = reader.read_some_member_bytes(&mut buf[..chunk_len])?;
if read == 0 {
return Err(FormatError::InvalidArchive("tar member group exceeds frame range").into());
}
*remaining -= read as u64;
pending -= read as u64;
handler.write_regular_payload(&buf[..read])?;
}
Ok(())
}
fn tar_member_group_end(stream: &[u8], start: usize) -> Result<usize, FormatError> {
let mut cursor = start;
let mut metadata = LocalMetadata::default();
loop {
let header = slice(stream, cursor, TAR_BLOCK_LEN)?;
if header.iter().all(|byte| *byte == 0) {
return Err(FormatError::InvalidArchive("tar member header is empty"));
}
verify_tar_checksum(header)?;
let typeflag = header[156];
let header_size = parse_tar_octal(&header[124..136])?;
let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
let effective_size = if is_main {
metadata.pax_size.unwrap_or(header_size)
} else {
header_size
};
let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
let payload_len = to_usize(effective_size)?;
let payload_end = checked_add(payload_start, payload_len)?;
let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
let payload = slice(stream, payload_start, payload_len)?;
if padded_end > stream.len() {
return Err(FormatError::InvalidArchive(
"tar member payload exceeds stream",
));
}
if stream[payload_end..padded_end]
.iter()
.any(|byte| *byte != 0)
{
return Err(FormatError::InvalidArchive(
"tar member padding is non-zero",
));
}
match typeflag {
b'x' => {
parse_pax_records(payload, &mut metadata)?;
cursor = padded_end;
}
b'L' | b'K' => {
cursor = padded_end;
}
b'g' => {
return Err(FormatError::InvalidArchive(
"global PAX headers are not allowed",
));
}
b'V' | b'M' | b'N' => {
return Err(FormatError::InvalidArchive(
"global GNU headers are not allowed",
));
}
b'S' => {
return Err(FormatError::ReaderUnsupported(
"unsupported GNU sparse tar entry",
));
}
0 | b'0' | b'5' | b'2' | b'1' => return Ok(padded_end),
_ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
}
if cursor >= stream.len() {
return Err(FormatError::InvalidArchive(
"tar member group has metadata records but no main entry",
));
}
}
}
pub fn restore_tar_member(
root: &Path,
member: &OwnedTarMember,
options: SafeExtractionOptions,
) -> Result<Vec<MetadataDiagnostic>, FormatError> {
let destination = prepare_destination(root, &member.path, member.kind, options)?;
let mut diagnostics = member.diagnostics.clone();
match member.kind {
TarEntryKind::Regular => {
let file = write_regular_file(&destination, &member.data, options)?;
apply_restored_regular_file_metadata(&file, member, &mut diagnostics);
}
TarEntryKind::Directory => create_directory(&destination)?,
TarEntryKind::Symlink => {
let target = member
.link_target
.as_deref()
.ok_or(FormatError::InvalidArchive("symlink target is missing"))?;
validate_symlink_target(&member.path, target)?;
create_symlink(&destination, target, options)?;
}
TarEntryKind::Hardlink => {
let target = member
.link_target
.as_deref()
.ok_or(FormatError::InvalidArchive("hardlink target is missing"))?;
let target_path = existing_safe_regular_path(root, target)?;
create_hardlink(&destination, &target_path, options)?;
}
}
Ok(diagnostics)
}
fn apply_restored_regular_file_metadata(
file: &fs::File,
member: &OwnedTarMember,
diagnostics: &mut Vec<MetadataDiagnostic>,
) {
apply_restored_regular_file_metadata_parts(file, member.mode, member.mtime, diagnostics);
}
fn apply_restored_regular_file_metadata_parts(
file: &fs::File,
mode: u32,
mtime: u64,
diagnostics: &mut Vec<MetadataDiagnostic>,
) {
apply_regular_file_mtime(file, mtime, diagnostics);
apply_regular_file_mode(file, mode, diagnostics);
}
#[cfg(unix)]
fn apply_regular_file_mode(file: &fs::File, mode: u32, diagnostics: &mut Vec<MetadataDiagnostic>) {
match file.metadata() {
Ok(metadata) => {
let mut permissions = metadata.permissions();
permissions.set_mode(mode & 0o7777);
if file.set_permissions(permissions).is_err() {
diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "failed to apply mode metadata",
});
}
}
Err(_) => diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "failed to apply mode metadata",
}),
}
}
#[cfg(not(unix))]
fn apply_regular_file_mode(file: &fs::File, mode: u32, diagnostics: &mut Vec<MetadataDiagnostic>) {
match file.metadata() {
Ok(metadata) => {
let mut permissions = metadata.permissions();
permissions.set_readonly(mode & 0o222 == 0);
if file.set_permissions(permissions).is_err() {
diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "failed to apply mode metadata",
});
return;
}
if mode & 0o777 != 0o444 && mode & 0o777 != 0o666 {
diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "mode metadata was only partially applied on this platform",
});
}
}
Err(_) => diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "failed to apply mode metadata",
}),
}
}
fn apply_regular_file_mtime(
file: &fs::File,
mtime: u64,
diagnostics: &mut Vec<MetadataDiagnostic>,
) {
let Some(modified) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(mtime)) else {
diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "failed to apply mtime metadata",
});
return;
};
let times = fs::FileTimes::new().set_modified(modified);
if file.set_times(times).is_err() {
diagnostics.push(MetadataDiagnostic {
profile: "ustar-baseline",
message: "failed to apply mtime metadata",
});
}
}
fn canonical_main_path(
header: &[u8],
kind: TarEntryKind,
metadata: &LocalMetadata,
max_path_length: u32,
) -> Result<Vec<u8>, FormatError> {
let mut path = metadata
.pax_path
.clone()
.or_else(|| metadata.gnu_long_name.clone())
.unwrap_or_else(|| ustar_path(header));
if kind == TarEntryKind::Directory && path.ends_with(b"/") && !path.ends_with(b"//") {
path.pop();
}
validate_file_path_bytes(&path, max_path_length)?;
Ok(path)
}
fn canonical_link_target(
header: &[u8],
kind: TarEntryKind,
link_path: &[u8],
metadata: &LocalMetadata,
max_path_length: u32,
) -> Result<Option<Vec<u8>>, FormatError> {
if !matches!(kind, TarEntryKind::Symlink | TarEntryKind::Hardlink) {
return Ok(None);
}
let target = metadata
.pax_linkpath
.clone()
.or_else(|| metadata.gnu_long_link.clone())
.unwrap_or_else(|| nul_trimmed(&header[157..257]).to_vec());
if target.is_empty() {
return Err(FormatError::UnsafeArchivePath);
}
match kind {
TarEntryKind::Hardlink => validate_file_path_bytes(&target, max_path_length)?,
TarEntryKind::Symlink => validate_symlink_target(link_path, &target)?,
_ => {}
}
Ok(Some(target))
}
fn parse_pax_records(payload: &[u8], metadata: &mut LocalMetadata) -> Result<(), FormatError> {
let mut cursor = 0usize;
while cursor < payload.len() {
let len_digits_start = cursor;
while cursor < payload.len() && payload[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor == len_digits_start || cursor >= payload.len() || payload[cursor] != b' ' {
return Err(FormatError::InvalidArchive("malformed PAX record"));
}
let len = parse_decimal(&payload[len_digits_start..cursor])?;
let record_start = len_digits_start;
let record_end = checked_add(record_start, len)?;
if record_end > payload.len() || len < 4 {
return Err(FormatError::InvalidArchive("malformed PAX record"));
}
let body_start = cursor + 1;
let record = &payload[body_start..record_end];
if record.last().copied() != Some(b'\n') {
return Err(FormatError::InvalidArchive("malformed PAX record"));
}
let body = &record[..record.len() - 1];
let eq = body
.iter()
.position(|byte| *byte == b'=')
.ok_or(FormatError::InvalidArchive("malformed PAX record"))?;
let key = std::str::from_utf8(&body[..eq])
.map_err(|_| FormatError::InvalidArchive("malformed PAX key"))?;
let value = &body[eq + 1..];
match key {
"path" => metadata.pax_path = Some(value.to_vec()),
"linkpath" => metadata.pax_linkpath = Some(value.to_vec()),
"size" => metadata.pax_size = Some(parse_decimal(value)? as u64),
"atime" | "ctime" | "mtime" => metadata.diagnostics.push(MetadataDiagnostic {
profile: "pax-posix-2001",
message: "unsupported PAX timestamp metadata was ignored",
}),
key if key.starts_with("SCHILY.xattr.")
|| key.starts_with("LIBARCHIVE.xattr.")
|| key.starts_with("SCHILY.acl.")
|| key.starts_with("LIBARCHIVE.acl.") =>
{
metadata.diagnostics.push(MetadataDiagnostic {
profile: "pax-xattrs-acls",
message: "unsupported xattr/ACL PAX metadata was ignored",
});
}
key if key.starts_with("GNU.sparse.") => {
metadata.diagnostics.push(MetadataDiagnostic {
profile: "gnu-sparse",
message: "unsupported sparse-file PAX metadata was ignored",
});
}
_ => metadata.diagnostics.push(MetadataDiagnostic {
profile: "pax-posix-2001",
message: "unsupported PAX key was ignored",
}),
}
cursor = record_end;
}
Ok(())
}
fn validate_symlink_target(link_path: &[u8], target: &[u8]) -> Result<(), FormatError> {
if target.is_empty()
|| target.contains(&0)
|| target.contains(&b'\\')
|| target.contains(&b':')
|| target[0] == b'/'
{
return Err(FormatError::UnsafeArchivePath);
}
let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
let link_path = std::str::from_utf8(link_path).map_err(|_| FormatError::UnsafeArchivePath)?;
let mut stack = link_path
.split('/')
.take(link_path.split('/').count().saturating_sub(1))
.map(str::to_owned)
.collect::<Vec<_>>();
for component in target.split('/') {
if component.is_empty() || component == "." {
return Err(FormatError::UnsafeArchivePath);
}
if component == ".." {
if stack.pop().is_none() {
return Err(FormatError::UnsafeArchivePath);
}
} else {
validate_file_path_bytes(component.as_bytes(), u32::MAX)?;
stack.push(component.to_owned());
}
}
Ok(())
}
fn prepare_destination(
root: &Path,
archive_path: &[u8],
kind: TarEntryKind,
options: SafeExtractionOptions,
) -> Result<PathBuf, FormatError> {
let components = path_components(archive_path)?;
validate_root(root)?;
let mut current = root.to_path_buf();
for component in &components[..components.len().saturating_sub(1)] {
current.push(component);
match fs::symlink_metadata(¤t) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() || !file_type.is_dir() {
return Err(FormatError::UnsafeArchivePath);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match fs::create_dir(¤t) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => {
return Err(FormatError::FilesystemExtractionFailed(
"failed to create parent directory",
));
}
}
let metadata = fs::symlink_metadata(¤t).map_err(|_| {
FormatError::FilesystemExtractionFailed(
"failed to inspect created parent directory",
)
})?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
return Err(FormatError::UnsafeArchivePath);
}
}
Err(_) => {
return Err(FormatError::FilesystemExtractionFailed(
"failed to inspect parent directory",
));
}
}
}
current.push(components.last().ok_or(FormatError::UnsafeArchivePath)?);
match fs::symlink_metadata(¤t) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(FormatError::UnsafeArchivePath);
}
if kind == TarEntryKind::Directory {
if file_type.is_dir() {
return Ok(current);
}
return Err(FormatError::UnsafeOverwrite);
}
if file_type.is_dir() {
return Err(FormatError::UnsafeOverwrite);
}
if !options.overwrite_existing {
return Err(FormatError::UnsafeOverwrite);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => {
return Err(FormatError::FilesystemExtractionFailed(
"failed to inspect destination",
));
}
}
Ok(current)
}
fn validate_root(root: &Path) -> Result<(), FormatError> {
let metadata = fs::symlink_metadata(root).map_err(|_| {
FormatError::FilesystemExtractionFailed("extraction root must already exist")
})?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
return Err(FormatError::UnsafeArchivePath);
}
Ok(())
}
fn existing_safe_regular_path(root: &Path, archive_path: &[u8]) -> Result<PathBuf, FormatError> {
validate_file_path_bytes(archive_path, u32::MAX)?;
let components = path_components(archive_path)?;
validate_root(root)?;
let mut current = root.to_path_buf();
for (idx, component) in components.iter().enumerate() {
current.push(component);
let metadata =
fs::symlink_metadata(¤t).map_err(|_| FormatError::UnsafeArchivePath)?;
if metadata.file_type().is_symlink() {
return Err(FormatError::UnsafeArchivePath);
}
if idx + 1 != components.len() {
if !metadata.file_type().is_dir() {
return Err(FormatError::UnsafeArchivePath);
}
} else if !metadata.file_type().is_file() {
return Err(FormatError::UnsafeArchivePath);
}
}
Ok(current)
}
fn write_regular_file(
destination: &Path,
data: &[u8],
options: SafeExtractionOptions,
) -> Result<fs::File, FormatError> {
if options.overwrite_existing && destination.exists() {
fs::remove_file(destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to create regular file"))?;
file.write_all(data)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))?;
Ok(file)
}
fn create_temp_regular_file(destination: &Path) -> Result<(PathBuf, fs::File), FormatError> {
let pid = std::process::id();
for attempt in 0..1000u32 {
let mut candidate = destination.as_os_str().to_os_string();
candidate.push(format!(".tzap-tmp-{pid}-{attempt}"));
let path = PathBuf::from(candidate);
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => return Ok((path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => {
return Err(FormatError::FilesystemExtractionFailed(
"failed to create regular file",
));
}
}
}
Err(FormatError::FilesystemExtractionFailed(
"failed to create regular file",
))
}
fn create_directory(destination: &Path) -> Result<(), FormatError> {
match fs::create_dir(destination) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let metadata =
fs::symlink_metadata(destination).map_err(|_| FormatError::UnsafeOverwrite)?;
if metadata.file_type().is_dir() {
Ok(())
} else {
Err(FormatError::UnsafeOverwrite)
}
}
Err(_) => Err(FormatError::FilesystemExtractionFailed(
"failed to create directory",
)),
}
}
fn create_hardlink(
destination: &Path,
target: &Path,
options: SafeExtractionOptions,
) -> Result<(), FormatError> {
if options.overwrite_existing && destination.exists() {
fs::remove_file(destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
}
fs::hard_link(target, destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to create hardlink"))
}
#[cfg(unix)]
fn create_symlink(
destination: &Path,
target: &[u8],
options: SafeExtractionOptions,
) -> Result<(), FormatError> {
if options.overwrite_existing && destination.exists() {
fs::remove_file(destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
}
let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
std::os::unix::fs::symlink(target, destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to create symlink"))
}
#[cfg(windows)]
fn create_symlink(
destination: &Path,
target: &[u8],
options: SafeExtractionOptions,
) -> Result<(), FormatError> {
if options.overwrite_existing && destination.exists() {
fs::remove_file(destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
}
let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
std::os::windows::fs::symlink_file(target, destination)
.map_err(|_| FormatError::FilesystemExtractionFailed("failed to create symlink"))
}
fn path_components(path: &[u8]) -> Result<Vec<String>, FormatError> {
validate_file_path_bytes(path, u32::MAX)?;
let path = std::str::from_utf8(path).map_err(|_| FormatError::UnsafeArchivePath)?;
Ok(path.split('/').map(str::to_owned).collect())
}
fn ustar_path(header: &[u8]) -> Vec<u8> {
let name = nul_trimmed(&header[0..100]);
let prefix = nul_trimmed(&header[345..500]);
if prefix.is_empty() {
name.to_vec()
} else {
let mut out = Vec::with_capacity(prefix.len() + 1 + name.len());
out.extend_from_slice(prefix);
out.push(b'/');
out.extend_from_slice(name);
out
}
}
fn trimmed_metadata_payload(payload: &[u8]) -> Vec<u8> {
let mut end = payload.len();
while end > 0 && payload[end - 1] == 0 {
end -= 1;
}
payload[..end].to_vec()
}
fn verify_tar_checksum(header: &[u8]) -> Result<(), FormatError> {
let stored = parse_tar_octal(&header[148..156])?;
let mut sum = 0u64;
for (idx, byte) in header.iter().enumerate() {
if (148..156).contains(&idx) {
sum += b' ' as u64;
} else {
sum += *byte as u64;
}
}
if stored != sum {
return Err(FormatError::InvalidArchive("tar header checksum mismatch"));
}
Ok(())
}
fn parse_tar_octal(field: &[u8]) -> Result<u64, FormatError> {
let mut value = 0u64;
let mut saw_digit = false;
for byte in field {
match *byte {
0 | b' ' if saw_digit => break,
0 | b' ' => {}
b'0'..=b'7' => {
saw_digit = true;
value = value
.checked_mul(8)
.and_then(|acc| acc.checked_add((*byte - b'0') as u64))
.ok_or(FormatError::InvalidArchive("tar octal field overflow"))?;
}
_ => return Err(FormatError::InvalidArchive("malformed tar octal field")),
}
}
Ok(value)
}
fn parse_decimal(field: &[u8]) -> Result<usize, FormatError> {
let mut value = 0usize;
if field.is_empty() {
return Err(FormatError::InvalidArchive("malformed decimal field"));
}
for byte in field {
if !byte.is_ascii_digit() {
return Err(FormatError::InvalidArchive("malformed decimal field"));
}
value = value
.checked_mul(10)
.and_then(|acc| acc.checked_add((byte - b'0') as usize))
.ok_or(FormatError::InvalidArchive("decimal field overflow"))?;
}
Ok(value)
}
fn nul_trimmed(bytes: &[u8]) -> &[u8] {
let end = bytes
.iter()
.position(|byte| *byte == 0)
.unwrap_or(bytes.len());
&bytes[..end]
}
fn padding_to_512(len: usize) -> usize {
let remainder = len % TAR_BLOCK_LEN;
if remainder == 0 {
0
} else {
TAR_BLOCK_LEN - remainder
}
}
fn padding_to_512_u64(len: u64) -> u64 {
let remainder = len % TAR_BLOCK_LEN as u64;
if remainder == 0 {
0
} else {
TAR_BLOCK_LEN as u64 - remainder
}
}
fn slice(bytes: &[u8], offset: usize, len: usize) -> Result<&[u8], FormatError> {
let end = checked_add(offset, len)?;
bytes.get(offset..end).ok_or(FormatError::InvalidLength {
structure: "tar member",
expected: end,
actual: bytes.len(),
})
}
fn checked_add(lhs: usize, rhs: usize) -> Result<usize, FormatError> {
lhs.checked_add(rhs).ok_or(FormatError::InvalidArchive(
"tar member arithmetic overflow",
))
}
fn to_usize(value: u64) -> Result<usize, FormatError> {
usize::try_from(value).map_err(|_| FormatError::InvalidArchive("tar member size overflow"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn header(path: &[u8], kind: u8, size: usize, link: &[u8]) -> [u8; TAR_BLOCK_LEN] {
let mut header = [0u8; TAR_BLOCK_LEN];
header[..path.len()].copy_from_slice(path);
write_octal(&mut header[100..108], 0o644);
write_octal(&mut header[108..116], 0);
write_octal(&mut header[116..124], 0);
write_octal(&mut header[124..136], size as u64);
write_octal(&mut header[136..148], 0);
header[148..156].fill(b' ');
header[156] = kind;
header[157..157 + link.len()].copy_from_slice(link);
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
write_checksum(&mut header[148..156], checksum);
header
}
fn member(path: &[u8], kind: u8, data: &[u8], link: &[u8]) -> Vec<u8> {
member_with_declared_size(path, kind, data.len(), data, link)
}
fn member_with_declared_size(
path: &[u8],
kind: u8,
declared_size: usize,
data: &[u8],
link: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&header(path, kind, declared_size, link));
out.extend_from_slice(data);
out.resize(out.len() + padding_to_512(data.len()), 0);
out
}
fn member_with_prefix(prefix: &[u8], path: &[u8], kind: u8, data: &[u8]) -> Vec<u8> {
let mut header = header(path, kind, data.len(), b"");
header[345..345 + prefix.len()].copy_from_slice(prefix);
header[148..156].fill(b' ');
let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
write_checksum(&mut header[148..156], checksum);
let mut out = Vec::new();
out.extend_from_slice(&header);
out.extend_from_slice(data);
out.resize(out.len() + padding_to_512(data.len()), 0);
out
}
fn pax_record(key: &str, value: &[u8]) -> Vec<u8> {
let mut len = key.len() + value.len() + 4;
loop {
let candidate = len.to_string().len() + 1 + key.len() + 1 + value.len() + 1;
if candidate == len {
break;
}
len = candidate;
}
let mut out = Vec::new();
out.extend_from_slice(len.to_string().as_bytes());
out.push(b' ');
out.extend_from_slice(key.as_bytes());
out.push(b'=');
out.extend_from_slice(value);
out.push(b'\n');
out
}
fn write_octal(field: &mut [u8], value: u64) {
let digits = format!("{value:o}");
field.fill(0);
let start = field.len() - 1 - digits.len();
field[..start].fill(b'0');
field[start..start + digits.len()].copy_from_slice(digits.as_bytes());
}
fn write_checksum(field: &mut [u8], value: u64) {
let digits = format!("{value:06o}");
field[0..6].copy_from_slice(digits.as_bytes());
field[6] = 0;
field[7] = b' ';
}
#[test]
fn parses_ustar_regular_member() {
let bytes = member(b"dir/file.txt", b'0', b"hello", b"");
let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
assert_eq!(parsed.kind, TarEntryKind::Regular);
assert_eq!(parsed.path, b"dir/file.txt");
assert_eq!(parsed.data, b"hello");
assert_eq!(parsed.logical_size, 5);
}
#[test]
fn canonicalizes_one_directory_trailing_slash_only_for_directories() {
let dir = member(b"dir/", b'5', b"", b"");
assert_eq!(parse_tar_member_group(&dir, 4096).unwrap().path, b"dir");
let file = member(b"dir/", b'0', b"", b"");
assert_eq!(
parse_tar_member_group(&file, 4096).unwrap_err(),
FormatError::UnsafeArchivePath
);
}
#[test]
fn rejects_global_pax_headers() {
let bytes = member(b"pax", b'g', b"11 path=x\n", b"");
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::InvalidArchive("global PAX headers are not allowed")
);
}
#[test]
fn rejects_global_pax_before_main_entry() {
let global_pax = pax_record("path", b"poisoned.txt");
let mut bytes = member(b"GlobalHead/path", b'g', &global_pax, b"");
bytes.extend_from_slice(&member(b"safe.txt", b'0', b"abc", b""));
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::InvalidArchive("global PAX headers are not allowed")
);
}
#[test]
fn rejects_global_gnu_headers() {
for typeflag in [b'V', b'M', b'N'] {
let bytes = member(b"global", typeflag, b"archive-label", b"");
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::InvalidArchive("global GNU headers are not allowed"),
"typeflag {typeflag:?}"
);
}
}
#[test]
fn rejects_unsupported_gnu_sparse_entry_type() {
let bytes = member(b"sparse.bin", b'S', b"", b"");
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::ReaderUnsupported("unsupported GNU sparse tar entry")
);
}
#[test]
fn applies_local_pax_path_and_size() {
let pax = pax_record("path", b"long/name.txt");
let mut bytes = member(b"PaxHeaders/name", b'x', &pax, b"");
bytes.extend_from_slice(&member(b"short", b'0', b"abc", b""));
let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
assert_eq!(parsed.path, b"long/name.txt");
assert_eq!(parsed.data, b"abc");
}
#[test]
fn applies_local_gnu_long_name_and_link_to_following_entry() {
let mut named = member(b"././@LongLink", b'L', b"long/path.txt\0", b"");
named.extend_from_slice(&member(b"short", b'0', b"abc", b""));
let parsed = parse_tar_member_group(&named, 4096).unwrap();
assert_eq!(parsed.path, b"long/path.txt");
assert_eq!(parsed.data, b"abc");
let mut linked = member(b"././@LongLink", b'K', b"target/file.txt\0", b"");
linked.extend_from_slice(&member(b"short-link", b'2', b"", b"fallback"));
let parsed = parse_tar_member_group(&linked, 4096).unwrap();
assert_eq!(parsed.path, b"short-link");
assert_eq!(
parsed.link_target.as_deref(),
Some(b"target/file.txt".as_slice())
);
}
#[test]
fn supported_tar_metadata_profile_matrix_matches_buffered_and_streaming_parsers() {
struct Case {
name: &'static str,
bytes: Vec<u8>,
expected_path: &'static [u8],
expected_kind: TarEntryKind,
expected_data: &'static [u8],
expected_link_target: Option<&'static [u8]>,
expected_logical_size: u64,
}
let mut pax_path_and_size = member(
b"PaxHeaders/pax-file",
b'x',
&[
pax_record("path", b"deep/pax-name.txt"),
pax_record("size", b"5"),
]
.concat(),
b"",
);
pax_path_and_size.extend_from_slice(&member_with_declared_size(
b"fallback",
b'0',
0,
b"hello",
b"",
));
let mut pax_linkpath = member(
b"PaxHeaders/pax-link",
b'x',
&pax_record("linkpath", b"target/file.txt"),
b"",
);
pax_linkpath.extend_from_slice(&member(b"links/link", b'2', b"", b"fallback-target"));
let mut gnu_long_name = member(b"././@LongLink", b'L', b"long/path/name.txt\0", b"");
gnu_long_name.extend_from_slice(&member(b"short", b'0', b"abc", b""));
let mut gnu_long_link = member(b"././@LongLink", b'K', b"target/hard.txt\0", b"");
gnu_long_link.extend_from_slice(&member(b"links/hard", b'1', b"", b"fallback"));
let cases = vec![
Case {
name: "regular ustar member",
bytes: member(b"dir/file.txt", b'0', b"hello", b""),
expected_path: b"dir/file.txt",
expected_kind: TarEntryKind::Regular,
expected_data: b"hello",
expected_link_target: None,
expected_logical_size: 5,
},
Case {
name: "ustar prefix plus name",
bytes: member_with_prefix(b"dir/prefix", b"file.txt", b'0', b"abc"),
expected_path: b"dir/prefix/file.txt",
expected_kind: TarEntryKind::Regular,
expected_data: b"abc",
expected_link_target: None,
expected_logical_size: 3,
},
Case {
name: "directory trailing slash",
bytes: member(b"dir/", b'5', b"", b""),
expected_path: b"dir",
expected_kind: TarEntryKind::Directory,
expected_data: b"",
expected_link_target: None,
expected_logical_size: 0,
},
Case {
name: "local pax path and size",
bytes: pax_path_and_size,
expected_path: b"deep/pax-name.txt",
expected_kind: TarEntryKind::Regular,
expected_data: b"hello",
expected_link_target: None,
expected_logical_size: 5,
},
Case {
name: "local pax linkpath",
bytes: pax_linkpath,
expected_path: b"links/link",
expected_kind: TarEntryKind::Symlink,
expected_data: b"",
expected_link_target: Some(b"target/file.txt"),
expected_logical_size: 0,
},
Case {
name: "gnu long name",
bytes: gnu_long_name,
expected_path: b"long/path/name.txt",
expected_kind: TarEntryKind::Regular,
expected_data: b"abc",
expected_link_target: None,
expected_logical_size: 3,
},
Case {
name: "gnu long link",
bytes: gnu_long_link,
expected_path: b"links/hard",
expected_kind: TarEntryKind::Hardlink,
expected_data: b"",
expected_link_target: Some(b"target/hard.txt"),
expected_logical_size: 0,
},
];
for case in cases {
let parsed = parse_tar_member_group(&case.bytes, 4096).unwrap_or_else(|err| {
panic!("{} should parse in buffered tar parser: {err:?}", case.name)
});
assert_eq!(parsed.path, case.expected_path, "{}", case.name);
assert_eq!(parsed.kind, case.expected_kind, "{}", case.name);
assert_eq!(parsed.data, case.expected_data, "{}", case.name);
assert_eq!(
parsed.link_target.as_deref(),
case.expected_link_target,
"{}",
case.name
);
assert_eq!(
parsed.logical_size, case.expected_logical_size,
"{}",
case.name
);
let mut streaming = TarStreamSummaryValidator::with_observer(
4096,
u64::MAX,
4096,
16,
NoopTarStreamObserver,
);
streaming.observe(&case.bytes).unwrap_or_else(|err| {
panic!(
"{} should parse in streaming tar parser: {err:?}",
case.name
)
});
let summary = streaming.finish().unwrap_or_else(|err| {
panic!(
"{} should finish in streaming tar parser: {err:?}",
case.name
)
});
assert_eq!(summary.members.len(), 1, "{}", case.name);
let member = &summary.members[0];
assert_eq!(member.path, case.expected_path, "{}", case.name);
assert_eq!(member.kind, case.expected_kind, "{}", case.name);
assert_eq!(
member.link_target.as_deref(),
case.expected_link_target,
"{}",
case.name
);
assert_eq!(
member.logical_size, case.expected_logical_size,
"{}",
case.name
);
}
}
#[test]
fn tar_metadata_rejects_unsafe_or_inconsistent_overrides_matrix() {
let mut pax_absolute_path = member(
b"PaxHeaders/file",
b'x',
&pax_record("path", b"/absolute"),
b"",
);
pax_absolute_path.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
let mut pax_parent_path = member(
b"PaxHeaders/file",
b'x',
&pax_record("path", b"../escape"),
b"",
);
pax_parent_path.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
let mut pax_absolute_link = member(
b"PaxHeaders/link",
b'x',
&pax_record("linkpath", b"/target"),
b"",
);
pax_absolute_link.extend_from_slice(&member(b"links/link", b'2', b"", b"safe"));
let mut gnu_unsafe_name = member(b"././@LongLink", b'L', b"bad:name.txt\0", b"");
gnu_unsafe_name.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
let mut gnu_parent_hardlink = member(b"././@LongLink", b'K', b"../target.txt\0", b"");
gnu_parent_hardlink.extend_from_slice(&member(b"links/hard", b'1', b"", b"safe"));
let mut pax_size_on_directory =
member(b"PaxHeaders/dir", b'x', &pax_record("size", b"1"), b"");
pax_size_on_directory
.extend_from_slice(&member_with_declared_size(b"dir", b'5', 0, b"x", b""));
for (name, bytes, expected) in [
(
"pax absolute path",
pax_absolute_path,
FormatError::UnsafeArchivePath,
),
(
"pax parent path",
pax_parent_path,
FormatError::UnsafeArchivePath,
),
(
"pax absolute symlink target",
pax_absolute_link,
FormatError::UnsafeArchivePath,
),
(
"gnu unsafe long name",
gnu_unsafe_name,
FormatError::UnsafeArchivePath,
),
(
"gnu hardlink parent target",
gnu_parent_hardlink,
FormatError::UnsafeArchivePath,
),
(
"pax size on directory",
pax_size_on_directory,
FormatError::InvalidArchive("non-regular tar entry has non-zero payload size"),
),
] {
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
expected,
"{name}"
);
let mut streaming = TarStreamSummaryValidator::with_observer(
4096,
u64::MAX,
4096,
16,
NoopTarStreamObserver,
);
assert_eq!(streaming.observe(&bytes).unwrap_err(), expected, "{name}");
}
}
#[test]
fn pax_size_exceeding_available_group_is_rejected_by_buffered_and_streaming_parsers() {
let mut bytes = member(b"PaxHeaders/file", b'x', &pax_record("size", b"4096"), b"");
bytes.extend_from_slice(&member_with_declared_size(b"file", b'0', 0, b"short", b""));
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::InvalidLength {
structure: "tar member",
expected: 5632,
actual: 2048,
}
);
let mut streaming = TarStreamSummaryValidator::with_observer(
4096,
u64::MAX,
4096,
16,
NoopTarStreamObserver,
);
streaming.observe(&bytes).unwrap();
assert_eq!(
streaming.finish().unwrap_err(),
FormatError::InvalidArchive("tar stream ended inside member group")
);
}
#[test]
fn malformed_pax_record_matrix_rejects_before_metadata_is_trusted() {
let cases: Vec<(&str, Vec<u8>)> = vec![
("missing length", b"path=file\n".to_vec()),
("missing space", b"12path=file\n".to_vec()),
("record too short", b"3 a\n".to_vec()),
("missing newline", b"11 path=file".to_vec()),
("missing equals", b"10 pathfile\n".to_vec()),
("non utf8 key", vec![7, b' ', 0xff, b'=', b'x', b'\n']),
("bad size value", pax_record("size", b"12x")),
];
for (name, payload) in cases {
let mut bytes = member(b"PaxHeaders/file", b'x', &payload, b"");
bytes.extend_from_slice(&member(b"file", b'0', b"abc", b""));
assert!(
matches!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::InvalidArchive(_)
),
"{name}"
);
let mut streaming = TarStreamSummaryValidator::with_observer(
4096,
u64::MAX,
4096,
16,
NoopTarStreamObserver,
);
assert!(
matches!(
streaming.observe(&bytes).unwrap_err(),
FormatError::InvalidArchive(_)
),
"{name}"
);
}
}
#[test]
fn reports_degraded_diagnostics_for_xattr_and_acl_pax_profiles() {
let mut pax = Vec::new();
pax.extend_from_slice(&pax_record("SCHILY.xattr.user.comment", b"hello"));
pax.extend_from_slice(&pax_record("LIBARCHIVE.xattr.user.comment", b"hello"));
pax.extend_from_slice(&pax_record("SCHILY.acl.access", b"user::rw-"));
pax.extend_from_slice(&pax_record("LIBARCHIVE.acl.access", b"user::rw-"));
let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
assert_eq!(parsed.path, b"file.txt");
assert_eq!(parsed.data, b"abc");
assert_eq!(
parsed
.diagnostics
.iter()
.filter(|diagnostic| **diagnostic
== MetadataDiagnostic {
profile: "pax-xattrs-acls",
message: "unsupported xattr/ACL PAX metadata was ignored",
})
.count(),
4
);
}
#[test]
fn reports_degraded_diagnostics_for_pax_timestamp_precision() {
let mut pax = Vec::new();
pax.extend_from_slice(&pax_record("atime", b"1.123456789"));
pax.extend_from_slice(&pax_record("ctime", b"2.123456789"));
pax.extend_from_slice(&pax_record("mtime", b"3.123456789"));
let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
assert_eq!(
parsed
.diagnostics
.iter()
.filter(|diagnostic| **diagnostic
== MetadataDiagnostic {
profile: "pax-posix-2001",
message: "unsupported PAX timestamp metadata was ignored",
})
.count(),
3
);
}
#[test]
fn reports_degraded_diagnostics_for_sparse_and_unknown_pax_profiles() {
let mut pax = Vec::new();
pax.extend_from_slice(&pax_record("GNU.sparse.realsize", b"1024"));
pax.extend_from_slice(&pax_record("GNU.sparse.map", b"0,1"));
pax.extend_from_slice(&pax_record("comment", b"ignored"));
let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
assert_eq!(
parsed
.diagnostics
.iter()
.filter(|diagnostic| **diagnostic
== MetadataDiagnostic {
profile: "gnu-sparse",
message: "unsupported sparse-file PAX metadata was ignored",
})
.count(),
2
);
assert_eq!(
parsed
.diagnostics
.iter()
.filter(|diagnostic| **diagnostic
== MetadataDiagnostic {
profile: "pax-posix-2001",
message: "unsupported PAX key was ignored",
})
.count(),
1
);
}
#[test]
fn reports_degraded_diagnostics_for_unsupported_local_pax_profiles() {
let mut pax = Vec::new();
pax.extend_from_slice(&pax_record("SCHILY.xattr.user.comment", b"hello"));
pax.extend_from_slice(&pax_record("GNU.sparse.realsize", b"1024"));
pax.extend_from_slice(&pax_record("mtime", b"1.123456789"));
pax.extend_from_slice(&pax_record("comment", b"ignored"));
let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
assert_eq!(parsed.path, b"file.txt");
assert_eq!(parsed.data, b"abc");
assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
profile: "pax-xattrs-acls",
message: "unsupported xattr/ACL PAX metadata was ignored",
}));
assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
profile: "gnu-sparse",
message: "unsupported sparse-file PAX metadata was ignored",
}));
assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
profile: "pax-posix-2001",
message: "unsupported PAX timestamp metadata was ignored",
}));
assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
profile: "pax-posix-2001",
message: "unsupported PAX key was ignored",
}));
}
#[test]
fn rejects_platform_escape_paths() {
for path in [
b"/abs".as_slice(),
b"../up".as_slice(),
b"a//b".as_slice(),
b"a\\b".as_slice(),
b"a:b".as_slice(),
b"CON".as_slice(),
] {
let bytes = member(path, b'0', b"", b"");
assert_eq!(
parse_tar_member_group(&bytes, 4096).unwrap_err(),
FormatError::UnsafeArchivePath
);
}
}
#[cfg(unix)]
#[test]
fn safe_restore_rejects_symlink_parent() {
let tmp = tempdir().unwrap();
let outside = tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap();
let member = OwnedTarMember {
path: b"link/file.txt".to_vec(),
kind: TarEntryKind::Regular,
data: b"blocked".to_vec(),
link_target: None,
mode: 0o644,
mtime: 0,
logical_size: 7,
diagnostics: Vec::new(),
};
assert_eq!(
restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
FormatError::UnsafeArchivePath
);
}
#[test]
fn safe_restore_requires_hardlink_target_to_be_existing_regular_file() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("target.txt"), b"target").unwrap();
let member = OwnedTarMember {
path: b"linked.txt".to_vec(),
kind: TarEntryKind::Hardlink,
data: Vec::new(),
link_target: Some(b"target.txt".to_vec()),
mode: 0o644,
mtime: 0,
logical_size: 0,
diagnostics: Vec::new(),
};
restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
}
#[cfg(unix)]
#[test]
fn restore_applies_regular_file_mode_metadata() {
let tmp = tempdir().unwrap();
let member = OwnedTarMember {
path: b"script.sh".to_vec(),
kind: TarEntryKind::Regular,
data: b"#!/bin/sh\n".to_vec(),
link_target: None,
mode: 0o755,
mtime: 0,
logical_size: 10,
diagnostics: Vec::new(),
};
let diagnostics =
restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
assert!(diagnostics.is_empty());
let mode = fs::metadata(tmp.path().join("script.sh"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o755);
}
#[test]
fn restore_applies_regular_file_mtime_metadata() {
let tmp = tempdir().unwrap();
let member = OwnedTarMember {
path: b"dated.txt".to_vec(),
kind: TarEntryKind::Regular,
data: b"dated".to_vec(),
link_target: None,
mode: 0o666,
mtime: 1_700_000_000,
logical_size: 5,
diagnostics: Vec::new(),
};
let diagnostics =
restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
assert!(diagnostics.is_empty());
let modified = fs::metadata(tmp.path().join("dated.txt"))
.unwrap()
.modified()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(modified, 1_700_000_000);
}
#[test]
fn restore_revalidates_symlink_targets_from_owned_members() {
let tmp = tempdir().unwrap();
let member = OwnedTarMember {
path: b"link".to_vec(),
kind: TarEntryKind::Symlink,
data: Vec::new(),
link_target: Some(b"/outside".to_vec()),
mode: 0o644,
mtime: 0,
logical_size: 0,
diagnostics: Vec::new(),
};
assert_eq!(
restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
FormatError::UnsafeArchivePath
);
assert!(!tmp.path().join("link").exists());
}
#[test]
fn safe_restore_rejects_directory_over_existing_file_even_with_overwrite() {
let tmp = tempdir().unwrap();
let conflict = tmp.path().join("conflict");
fs::write(&conflict, b"not a directory").unwrap();
let member = OwnedTarMember {
path: b"conflict".to_vec(),
kind: TarEntryKind::Directory,
data: Vec::new(),
link_target: None,
mode: 0o644,
mtime: 0,
logical_size: 0,
diagnostics: Vec::new(),
};
assert_eq!(
restore_tar_member(
tmp.path(),
&member,
SafeExtractionOptions {
overwrite_existing: true
}
)
.unwrap_err(),
FormatError::UnsafeOverwrite
);
assert!(conflict.is_file());
}
#[test]
fn hardlink_target_checks_use_component_position_not_value() {
let tmp = tempdir().unwrap();
fs::create_dir(tmp.path().join("a")).unwrap();
fs::write(tmp.path().join("a").join("a"), b"target").unwrap();
let member = OwnedTarMember {
path: b"linked.txt".to_vec(),
kind: TarEntryKind::Hardlink,
data: Vec::new(),
link_target: Some(b"a/a".to_vec()),
mode: 0o644,
mtime: 0,
logical_size: 0,
diagnostics: Vec::new(),
};
restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
}
#[test]
fn hardlink_targets_obey_max_path_length() {
let bytes = member(b"link", b'1', b"", b"long/name");
assert_eq!(
parse_tar_member_group(&bytes, 4).unwrap_err(),
FormatError::UnsafeArchivePath
);
}
}