use crate::detect;
use crate::ramdisk::{Compression, compression_of, decoder};
use crate::treeout::{Sink, clean_path, host_collisions, prepare_staging, publish};
use anyhow::{Context, Result, anyhow, bail, ensure};
use md5::{Digest, Md5};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
use tar::Archive;
const TAR_USTAR_OFF: usize = 257;
const TAR_USTAR_MAGIC: &[u8] = b"ustar";
const SNIFF_LEN: usize = 512;
#[allow(dead_code)]
pub(crate) enum Kind {
Tar,
Compressed(Compression),
TarMd5,
}
fn is_tar_head(head: &[u8]) -> bool {
head.len() >= TAR_USTAR_OFF + TAR_USTAR_MAGIC.len()
&& &head[TAR_USTAR_OFF..TAR_USTAR_OFF + TAR_USTAR_MAGIC.len()] == TAR_USTAR_MAGIC
}
pub(crate) fn kind_of(head: &[u8]) -> Option<Kind> {
if is_tar_head(head) {
return Some(Kind::Tar);
}
compression_of(head).map(Kind::Compressed)
}
pub(crate) fn compressed_holds_tar(c: Compression, r: impl Read) -> bool {
let Ok(mut d) = decoder(c, BufReader::new(r)) else {
return false;
};
let mut block = [0u8; SNIFF_LEN];
let mut got = 0;
while got < block.len() {
match d.read(&mut block[got..]) {
Ok(0) | Err(_) => break,
Ok(n) => got += n,
}
}
is_tar_head(&block[..got])
}
pub(crate) fn holds_tar(path: &Path, head: &[u8]) -> bool {
is_tar_head(head)
|| compression_of(head)
.is_some_and(|c| File::open(path).is_ok_and(|f| compressed_holds_tar(c, f)))
}
fn first_bytes(path: &Path, n: usize) -> Result<Vec<u8>> {
detect::refuse_blocking_file(path)?;
let mut buf = vec![0u8; n];
let mut f = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let got = f.read(&mut buf)?;
buf.truncate(got);
Ok(buf)
}
fn split_md5(data: &[u8]) -> Result<(Vec<u8>, String)> {
let end = data
.iter()
.rposition(|&c| c > 0x20)
.map(|i| i + 1)
.unwrap_or(0);
let split = data[..end]
.iter()
.rposition(|&c| !(0x20..=0x7e).contains(&c))
.map(|i| i + 1)
.unwrap_or(0);
let line = std::str::from_utf8(&data[split..])
.context("tar.md5 trailing line is not valid UTF-8")?
.trim();
let parts: Vec<&str> = line.split_whitespace().collect();
ensure!(
parts.len() == 3 && parts[1] == "-" && parts[2] == "-",
"tar.md5 trailing line is not the expected \"<md5> - -\" format"
);
Ok((data[..split].to_vec(), parts[0].to_ascii_lowercase()))
}
#[derive(Debug)]
pub(crate) struct IntegrityError(pub String);
impl std::fmt::Display for IntegrityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for IntegrityError {}
fn verify_md5(data: &[u8], expected: &str) -> Result<()> {
let computed: String = Md5::digest(data)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
if computed != expected.to_lowercase() {
return Err(IntegrityError(format!(
"MD5 mismatch: the file says {expected}, the contents hash to {computed}"
))
.into());
}
Ok(())
}
pub(crate) fn extract(input: &Path, out_dir: &Path) -> Result<()> {
let head = first_bytes(input, SNIFF_LEN)?;
if is_tar_head(&head) {
if input.extension().and_then(|e| e.to_str()) == Some("md5") {
return extract_tar_md5(input, out_dir);
}
return extract_stream(out_dir, BufReader::new(File::open(input)?));
}
if let Some(c) = compression_of(&head) {
let f = BufReader::new(File::open(input)?);
return extract_stream(out_dir, decoder(c, f)?);
}
bail!("{} is not a tar or compressed-tar archive", input.display())
}
fn extract_tar_md5(input: &Path, out_dir: &Path) -> Result<()> {
let data = std::fs::read(input).with_context(|| format!("reading {}", input.display()))?;
let (tar_bytes, hash) = split_md5(&data)?;
verify_md5(&tar_bytes, &hash)?;
let staging = prepare_staging(out_dir)?;
let result = unpack(Box::new(BufReader::new(tar_bytes.as_slice())), &staging);
if result.is_ok() {
publish(&staging, out_dir)?;
} else {
let _ = std::fs::remove_dir_all(&staging);
}
result
}
fn extract_stream(out_dir: &Path, reader: impl Read) -> Result<()> {
let staging = prepare_staging(out_dir)?;
let result = unpack(reader, &staging);
if result.is_ok() {
publish(&staging, out_dir)?;
} else {
let _ = std::fs::remove_dir_all(&staging);
}
result
}
fn unpack(mut reader: impl Read, staging: &Path) -> Result<()> {
let files = staging.join("files");
std::fs::create_dir_all(&files)?;
let mut sink = Sink::with_collisions(&files, host_collisions(&files));
let mut ar = Archive::new(&mut reader);
for entry in ar.entries()? {
let mut entry = entry?;
extract_entry(&mut entry, &mut sink)?;
}
Ok(())
}
fn extract_entry(entry: &mut tar::Entry<impl Read>, sink: &mut Sink) -> Result<()> {
let path = entry.path()?;
let raw = path
.to_str()
.ok_or_else(|| anyhow!("tar entry has a non-UTF-8 path"))?;
let name = clean_path(raw).with_context(|| format!("tar entry {raw:?}"))?;
let kind = entry.header().entry_type();
if kind.is_dir() {
sink.add_dir(&name)?;
return Ok(());
}
if kind.is_symlink() || kind.is_hard_link() {
sink.note_symlink(&name)?;
return Ok(());
}
if kind.is_character_special() || kind.is_block_special() || kind.is_fifo() {
bail!("tar entry {name:?} is a device or fifo, which is never created");
}
if kind.is_file() || kind.is_contiguous() {
let mode = entry.header().mode().unwrap_or(0o644);
sink.write_file(&name, mode, &mut *entry)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::Scratch;
use flate2::{Compression as Gz, write::GzEncoder};
const BOOT: &[u8] = b"BOOT-IMAGE-BYTES";
const SYSTEM: &[u8] = b"SYSTEM-IMAGE-BYTES";
fn sample_tar() -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
for (name, body) in [("boot.img", BOOT), ("system.img", SYSTEM)] {
let mut h = tar::Header::new_gnu();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_cksum();
b.append_data(&mut h, name, body).unwrap();
}
b.into_inner().unwrap()
}
fn gzip(data: &[u8]) -> Vec<u8> {
let mut e = GzEncoder::new(Vec::new(), Gz::default());
std::io::Write::write_all(&mut e, data).unwrap();
e.finish().unwrap()
}
fn md5_hex(data: &[u8]) -> String {
Md5::digest(data)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn run(src: &Path, out: &Path) -> Result<()> {
extract(src, out)
}
#[test]
fn a_plain_tar_round_trips() {
let s = Scratch::new("arc-tar");
let src = s.join("payload.tar");
std::fs::write(&src, sample_tar()).unwrap();
let out = s.join("out");
run(&src, &out).unwrap();
assert_eq!(std::fs::read(out.join("files/boot.img")).unwrap(), BOOT);
assert_eq!(std::fs::read(out.join("files/system.img")).unwrap(), SYSTEM);
}
#[test]
fn a_gzip_wrapped_tar_round_trips() {
let s = Scratch::new("arc-targz");
let src = s.join("payload.tar.gz");
std::fs::write(&src, gzip(&sample_tar())).unwrap();
let out = s.join("out");
run(&src, &out).unwrap();
assert_eq!(std::fs::read(out.join("files/boot.img")).unwrap(), BOOT);
}
#[test]
fn a_jios_tb_tar_md5_is_verified_then_extracted() {
let s = Scratch::new("arc-tarmd5");
let tar = sample_tar();
let mut blob = tar.clone();
blob.extend_from_slice(format!("{} - -\n", md5_hex(&tar)).as_bytes());
let src = s.join("SUPER.tar.md5");
std::fs::write(&src, &blob).unwrap();
let out = s.join("out");
run(&src, &out).unwrap();
assert_eq!(std::fs::read(out.join("files/boot.img")).unwrap(), BOOT);
assert_eq!(std::fs::read(out.join("files/system.img")).unwrap(), SYSTEM);
}
#[test]
fn a_tar_md5_that_does_not_match_is_refused() {
let s = Scratch::new("arc-badmd5");
let mut blob = sample_tar();
blob.extend_from_slice(b"00000000000000000000000000000000 - -\n");
let src = s.join("SUPER.tar.md5");
std::fs::write(&src, &blob).unwrap();
let out = s.join("out");
let e = run(&src, &out).unwrap_err().to_string();
assert!(e.contains("MD5 mismatch"), "{e}");
assert!(
!out.exists(),
"nothing is published when the hash does not match"
);
}
#[test]
fn an_entry_that_climbs_out_is_refused() {
let s = Scratch::new("arc-traversal");
let body = b"escaped";
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
let name = b"../evil";
header.as_old_mut().name[..name.len()].copy_from_slice(name);
header.set_cksum();
let mut tar_bytes = header.as_bytes().to_vec();
tar_bytes.extend_from_slice(body);
tar_bytes.resize(10240, 0);
tar_bytes[257..262].copy_from_slice(b"ustar");
let src = s.join("evil.tar");
std::fs::write(&src, &tar_bytes).unwrap();
let out = s.join("out");
let e = format!("{:#}", run(&src, &out).unwrap_err());
assert!(e.contains("climbs out"), "{e}");
assert!(!out.exists(), "nothing is published when an entry escapes");
assert!(
!s.join("evil").exists(),
"nothing was written outside the output directory"
);
}
#[test]
fn a_device_entry_is_refused() {
let s = Scratch::new("arc-device");
let mut b = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_size(0);
h.set_mode(0o644);
h.set_entry_type(tar::EntryType::Char);
let _ = h.set_device_major(1);
let _ = h.set_device_minor(3);
h.set_path("dev/null").unwrap();
h.set_cksum();
b.append(&h, std::io::empty()).unwrap();
let mut tar_bytes = b.into_inner().unwrap();
tar_bytes[257..262].copy_from_slice(b"ustar");
let src = s.join("dev.tar");
std::fs::write(&src, &tar_bytes).unwrap();
let out = s.join("out");
let e = run(&src, &out).unwrap_err().to_string();
assert!(e.contains("device") || e.contains("fifo"), "{e}");
assert!(!out.exists());
}
#[test]
fn a_failed_extraction_leaves_no_staging_directory() {
let s = Scratch::new("arc-staging");
let src = s.join("payload.tar");
std::fs::write(&src, sample_tar()).unwrap();
let out = s.join("out");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join("in-the-way"), b"x").unwrap();
assert!(run(&src, &out).is_err());
assert!(
!s.join("out.part").exists(),
"the staging directory is cleaned up"
);
}
}