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 name: String,
pub kind: TarKind,
pub size: u64,
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 {
name,
kind,
size,
data: data_start..data_end,
link_target,
});
}
}
if short_content {
break; }
offset = block_end;
}
Ok(entries)
}
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_names_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].name, "data");
assert_eq!(entries[0].kind, TarKind::Dir);
assert_eq!(entries[1].kind, TarKind::File);
assert_eq!(entries[1].size, 12);
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 = parse(&bytes, true).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, 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 = parse(&bytes, true).unwrap();
assert_eq!(entries[0].name, "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].size, 1000);
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");
}
}