use std::ops::Range;
const BLOCK: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TarKind {
File,
Dir,
Symlink,
Other,
}
#[derive(Debug)]
pub(crate) struct TarEntry {
pub kind: TarKind,
pub data: Range<usize>,
pub link_target: Option<String>,
}
pub(crate) fn parse(bytes: &[u8], complete: bool) -> Result<Vec<TarEntry>, String> {
let mut entries = Vec::new();
let mut pending_name: Option<String> = None;
let mut offset = 0;
loop {
let Some(header) = bytes.get(offset..offset + BLOCK) else {
let rest = &bytes[offset.min(bytes.len())..];
if complete && rest.iter().any(|&b| b != 0) {
return Err("truncated tar header".into());
}
break;
};
if header.iter().all(|&b| b == 0) {
break; }
if &header[257..262] != b"ustar" {
return Err("not a ustar archive".into());
}
let size = octal(&header[124..136])?;
let typeflag = header[156];
let data_start = offset + BLOCK;
let padded = (size as usize).saturating_add(BLOCK - 1) / BLOCK * BLOCK;
let data_end = data_start.saturating_add(size as usize).min(bytes.len());
let block_end = data_start.saturating_add(padded).min(bytes.len());
let short_content = block_end - data_start < padded;
if short_content && complete {
return Err("truncated tar content".into());
}
let content = &bytes[data_start..data_end];
match typeflag {
b'L' => pending_name = Some(nul_terminated(content)?),
b'x' => {
if let Some(path) = pax_path(content)? {
pending_name = Some(path);
}
}
b'K' | b'g' => {} flag => {
let _name = match pending_name.take() {
Some(name) => name,
None => header_name(header)?,
};
let kind = match flag {
b'0' | 0 => TarKind::File,
b'5' => TarKind::Dir,
b'2' => TarKind::Symlink,
_ => TarKind::Other,
};
let link_target = (kind == TarKind::Symlink)
.then(|| nul_terminated(&header[157..257]))
.transpose()?;
entries.push(TarEntry {
kind,
data: data_start..data_end,
link_target,
});
}
}
if short_content {
break; }
offset = block_end;
}
Ok(entries)
}
#[derive(Debug)]
pub(crate) struct StreamEntry {
pub name: String,
pub kind: TarKind,
pub size: u64,
pub link_target: Option<String>,
}
pub(crate) struct StreamParser {
header: [u8; BLOCK],
header_len: usize,
content: Vec<u8>,
content_size: u64,
content_left: u64,
pad_left: u64,
keep_content: bool,
pending_flag: u8,
pending_name: Option<String>,
ended: bool,
}
impl Default for StreamParser {
fn default() -> Self {
Self {
header: [0u8; BLOCK],
header_len: 0,
content: Vec::new(),
content_size: 0,
content_left: 0,
pad_left: 0,
keep_content: false,
pending_flag: 0,
pending_name: None,
ended: false,
}
}
}
impl StreamParser {
pub(crate) fn feed(
&mut self,
mut bytes: &[u8],
on_entry: &mut impl FnMut(StreamEntry),
) -> Result<(), String> {
while !bytes.is_empty() {
if self.ended {
return Ok(());
}
if self.pad_left > 0 {
let take = self.pad_left.min(bytes.len() as u64) as usize;
self.pad_left -= take as u64;
bytes = &bytes[take..];
continue;
}
if self.content_left > 0 {
let take = self.content_left.min(bytes.len() as u64) as usize;
if self.keep_content {
self.content.extend_from_slice(&bytes[..take]);
}
self.content_left -= take as u64;
bytes = &bytes[take..];
if self.content_left == 0 {
self.pad_left =
(BLOCK as u64 - self.content_size % BLOCK as u64) % BLOCK as u64;
self.finish_record()?;
self.content.clear();
}
continue;
}
let take = (BLOCK - self.header_len).min(bytes.len());
self.header[self.header_len..self.header_len + take].copy_from_slice(&bytes[..take]);
self.header_len += take;
bytes = &bytes[take..];
if self.header_len == BLOCK {
self.on_header(on_entry)?;
self.header_len = 0;
}
}
Ok(())
}
pub(crate) fn finish(&self) -> Result<(), String> {
if self.ended {
return Ok(());
}
if self.content_left > 0 || self.pad_left > 0 {
return Err("truncated tar content".into());
}
if self.header_len > 0 && self.header[..self.header_len].iter().any(|&b| b != 0) {
return Err("truncated tar header".into());
}
Ok(())
}
fn on_header(&mut self, on_entry: &mut impl FnMut(StreamEntry)) -> Result<(), String> {
let header = &self.header;
if header.iter().all(|&b| b == 0) {
self.ended = true;
return Ok(());
}
if &header[257..262] != b"ustar" {
return Err("not a ustar archive".into());
}
let size = octal(&header[124..136])?;
let flag = header[156];
self.content_size = size;
self.content_left = size;
self.keep_content = matches!(flag, b'L' | b'x');
self.pending_flag = flag;
match flag {
b'L' | b'x' | b'K' | b'g' => {} flag => {
let name = match self.pending_name.take() {
Some(name) => name,
None => header_name(header)?,
};
let kind = match flag {
b'0' | 0 => TarKind::File,
b'5' => TarKind::Dir,
b'2' => TarKind::Symlink,
_ => TarKind::Other,
};
let link_target = (kind == TarKind::Symlink)
.then(|| nul_terminated(&header[157..257]))
.transpose()?;
on_entry(StreamEntry {
name,
kind,
size,
link_target,
});
}
}
Ok(())
}
fn finish_record(&mut self) -> Result<(), String> {
match self.pending_flag {
b'L' => self.pending_name = Some(nul_terminated(&self.content)?),
b'x' => {
if let Some(path) = pax_path(&self.content)? {
self.pending_name = Some(path);
}
}
_ => {}
}
Ok(())
}
}
fn header_name(header: &[u8]) -> Result<String, String> {
let name = nul_terminated(&header[0..100])?;
let prefix = nul_terminated(&header[345..500])?;
if prefix.is_empty() {
Ok(name)
} else {
Ok(format!("{prefix}/{name}"))
}
}
fn octal(field: &[u8]) -> Result<u64, String> {
if field.first().is_some_and(|b| b & 0x80 != 0) {
return Err("base-256 tar numeric field refused".into());
}
let text = nul_terminated(field)?;
let digits = text.trim_end_matches(' ');
if digits.is_empty() {
return Ok(0);
}
u64::from_str_radix(digits, 8).map_err(|_| format!("malformed octal field {digits:?}"))
}
fn nul_terminated(bytes: &[u8]) -> Result<String, String> {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
std::str::from_utf8(&bytes[..end])
.map(str::to_string)
.map_err(|_| "non-UTF-8 tar name".into())
}
fn pax_path(content: &[u8]) -> Result<Option<String>, String> {
let mut path = None;
let mut cursor = 0;
while cursor < content.len() {
let gap = content[cursor..]
.iter()
.position(|&b| b == b' ')
.ok_or("malformed pax record length")?;
let digits = std::str::from_utf8(&content[cursor..cursor + gap])
.map_err(|_| "malformed pax record length")?;
let length: usize = digits.parse().map_err(|_| "malformed pax record length")?;
let record = content
.get(cursor + gap + 1..cursor + length)
.filter(|_| length > gap + 1)
.ok_or("pax record overruns its header")?;
if let Some(value) = record.strip_prefix(b"path=") {
let value = value.strip_suffix(b"\n").unwrap_or(value);
path = Some(
std::str::from_utf8(value)
.map_err(|_| "non-UTF-8 pax path")?
.to_string(),
);
}
cursor += length;
}
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, typeflag: u8, content: &[u8]) -> Vec<u8> {
let mut header = [0u8; BLOCK];
let name_bytes = name.as_bytes();
assert!(name_bytes.len() <= 100);
header[..name_bytes.len()].copy_from_slice(name_bytes);
let size = format!("{:011o}", content.len());
header[124..124 + size.len()].copy_from_slice(size.as_bytes());
header[257..262].copy_from_slice(b"ustar");
header[156] = typeflag;
let mut out = header.to_vec();
out.extend_from_slice(content);
out.resize(out.len() + (BLOCK - content.len() % BLOCK) % BLOCK, 0);
out
}
fn archive(parts: &[Vec<u8>]) -> Vec<u8> {
let mut out = parts.concat();
out.extend_from_slice(&[0u8; BLOCK]); out
}
#[test]
fn parses_kinds_and_content_offsets() {
let mut link = entry("data/link", b'2', b"");
link[157..157 + 9].copy_from_slice(b"hello.txt");
let bytes = archive(&[
entry("data", b'5', b""),
entry("data/hello.txt", b'0', b"hello strop\n"),
link,
entry("data/fifo", b'6', b""),
]);
let entries = parse(&bytes, true).unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].kind, TarKind::Dir);
assert_eq!(entries[1].kind, TarKind::File);
assert_eq!(&bytes[entries[1].data.clone()], b"hello strop\n");
assert_eq!(entries[2].kind, TarKind::Symlink);
assert_eq!(entries[2].link_target.as_deref(), Some("hello.txt"));
assert_eq!(entries[3].kind, TarKind::Other);
assert_eq!(entries[3].link_target, None);
}
#[test]
fn gnu_longname_overrides_the_header_field() {
let long = format!("dir/{}", "x".repeat(120));
let bytes = archive(&[
entry("longname", b'L', format!("{long}\0").as_bytes()),
entry("truncated", b'0', b"abc"),
]);
let entries = streamed(&bytes, 65536).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, long);
}
#[test]
fn pax_path_overrides_the_header_field() {
let record_body = "path=pax/spelled name.txt\n";
let record = format!("{} {}", record_body.len() + 3, record_body);
let bytes = archive(&[
entry("pax", b'x', record.as_bytes()),
entry("field", b'0', b"z"),
]);
let entries = streamed(&bytes, 65536).unwrap();
assert_eq!(entries[0].0, "pax/spelled name.txt");
}
#[test]
fn a_truncated_capture_keeps_the_final_entries_prefix_only() {
let full = entry("big.bin", b'0', &[7u8; 1000]);
let cut = &full[..BLOCK + 400]; let entries = parse(cut, false).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].data.len(), 400);
assert!(parse(cut, true).is_err(), "complete stream must not lie");
assert!(parse(&cut[..BLOCK + 100], true).is_err());
assert!(parse(&cut[..100], false).is_ok(), "zero-padded tail ends");
}
#[test]
fn structural_garbage_is_an_error() {
assert!(parse(b"not a tar at all", true).is_err());
let mut bad = entry("a", b'0', b"");
bad[257..262].copy_from_slice(b"nope!");
assert!(parse(&bad, true).is_err(), "bad magic");
let mut bad = entry("a", b'0', b"");
bad[124] = 0x80; assert!(parse(&bad, true).is_err(), "base-256 refused");
}
type StreamedEntry = (String, TarKind, u64, Option<String>);
fn streamed(bytes: &[u8], chunk: usize) -> Result<Vec<StreamedEntry>, String> {
let mut parser = StreamParser::default();
let mut entries = Vec::new();
for piece in bytes.chunks(chunk) {
parser.feed(piece, &mut |entry| {
entries.push((entry.name, entry.kind, entry.size, entry.link_target));
})?;
}
parser.finish()?;
Ok(entries)
}
fn mixed_archive() -> Vec<u8> {
let long = format!("data/{}", "x".repeat(120));
let mut longname = entry("longname", b'L', format!("{long}\0").as_bytes());
let mut link = entry("data/link", b'2', b"");
link[157..157 + 9].copy_from_slice(b"hello.txt");
let record_body = "path=data/pax name.txt\n";
let record = format!("{} {}", record_body.len() + 3, record_body);
archive(&[
entry("data", b'5', b""),
entry("data/hello.txt", b'0', b"hello strop\n"),
entry("data/sub", b'5', b""),
entry("data/sub/blob.bin", b'0', &[3u8; 3000]),
{
longname.append(&mut entry("truncated", b'0', b"abc"));
longname
},
entry("pax", b'x', record.as_bytes()),
entry("field", b'0', b"z"),
link,
])
}
#[test]
fn streaming_parses_all_entry_shapes_across_chunkings() {
let bytes = mixed_archive();
let long = format!("data/{}", "x".repeat(120));
let expected: Vec<(String, TarKind, u64, Option<String>)> = vec![
("data".into(), TarKind::Dir, 0, None),
("data/hello.txt".into(), TarKind::File, 12, None),
("data/sub".into(), TarKind::Dir, 0, None),
("data/sub/blob.bin".into(), TarKind::File, 3000, None),
(long, TarKind::File, 3, None),
("data/pax name.txt".into(), TarKind::File, 1, None),
(
"data/link".into(),
TarKind::Symlink,
0,
Some("hello.txt".into()),
),
];
for chunk in [1, 7, 100, 511, 512, 513, 4096, bytes.len()] {
assert_eq!(
streamed(&bytes, chunk).as_deref(),
Ok(expected.as_slice()),
"chunk size {chunk}"
);
}
}
#[test]
fn stream_parser_reports_truncation() {
let full = archive(&[
entry("data", b'5', b""),
entry("data/big.bin", b'0', &[9u8; 1000]),
]);
let mid_content = &full[..BLOCK + BLOCK + 400];
assert!(streamed(mid_content, 65536).is_err(), "content cut short");
let mid_header = &full[..BLOCK + 100];
assert!(streamed(mid_header, 65536).is_err(), "header cut short");
let boundary = &full[..full.len() - BLOCK];
let entries = streamed(boundary, 65536).unwrap();
assert_eq!(entries.len(), 2);
let zero_tail = &full[..full.len() - BLOCK + 100];
assert!(streamed(zero_tail, 65536).is_ok());
}
#[test]
fn stream_parser_rejects_structural_garbage() {
assert!(streamed(b"not a tar at all", 4).is_err());
let mut bad = archive(&[entry("a", b'0', b"")]);
bad[257..262].copy_from_slice(b"nope!");
assert!(streamed(&bad, 65536).is_err(), "bad magic");
let mut bad = archive(&[entry("a", b'0', b"")]);
bad[124] = 0x80; assert!(streamed(&bad, 3).is_err(), "base-256 refused");
}
#[test]
fn subtree_content_streams_without_shaping_headers() {
let bulk = vec![5u8; 1024 * 1024];
let mut parts = vec![entry("data", b'5', b""), entry("data/deep", b'5', b"")];
for index in 0..4 {
parts.push(entry(&format!("data/deep/f{index}"), b'0', &bulk));
}
let bytes = archive(&parts);
let entries = streamed(&bytes, 65536).unwrap();
let names: Vec<&str> = entries.iter().map(|(name, ..)| name.as_str()).collect();
assert_eq!(
names,
[
"data",
"data/deep",
"data/deep/f0",
"data/deep/f1",
"data/deep/f2",
"data/deep/f3"
]
);
assert!(entries[2..]
.iter()
.all(|(_, kind, size, _)| { *kind == TarKind::File && *size == 1024 * 1024 }));
}
}