#![allow(clippy::unwrap_used)]
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use exarch_core::ExtractionOptions;
use exarch_core::ManifestEntryKind;
use exarch_core::SecurityConfig;
use exarch_core::extract_archive;
use exarch_core::extract_archive_with_options;
use exarch_core::list_archive;
use exarch_core::verify_archive;
use flate2::Compression;
use flate2::write::GzEncoder;
use tempfile::TempDir;
const ROOT_NAMES: [&str; 4] = [".", "./", "./.", ".//"];
const FILE_ROOT_NAMES: [&str; 2] = [".", "./."];
fn entry(name: &str, kind: tar::EntryType, data: &[u8], link: Option<&str>) -> Vec<u8> {
let mut header = tar::Header::new_gnu();
header.set_entry_type(kind);
header.set_mode(0o644);
header.set_size(data.len() as u64);
header.as_old_mut().name[..name.len()].copy_from_slice(name.as_bytes());
if let Some(link) = link {
header.set_link_name(link).unwrap();
}
header.set_cksum();
let mut bytes = header.as_bytes().to_vec();
bytes.extend_from_slice(data);
bytes.resize(bytes.len().next_multiple_of(512), 0);
bytes
}
fn tar_of(entries: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = entries.concat();
bytes.resize(bytes.len() + 1024, 0);
bytes
}
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(bytes).unwrap();
encoder.finish().unwrap()
}
fn write_archive(dir: &TempDir, file_name: &str, bytes: &[u8]) -> PathBuf {
let path = dir.path().join(file_name);
std::fs::write(&path, bytes).unwrap();
path
}
fn link_config() -> SecurityConfig {
SecurityConfig::default()
.with_allow_symlinks(true)
.with_allow_hardlinks(true)
}
fn assert_rejected_everywhere(archive: &Path, config: &SecurityConfig, max_written: usize) {
let out = TempDir::new().unwrap();
let extract = extract_archive(archive, out.path(), config).unwrap_err();
assert_eq!(extract.error_code(), "SECURITY_VIOLATION", "{extract}");
let list = list_archive(archive, config).unwrap_err();
assert_eq!(list.error_code(), "SECURITY_VIOLATION", "{list}");
let verify = verify_archive(archive, config).unwrap_err();
assert_eq!(verify.error_code(), "SECURITY_VIOLATION", "{verify}");
assert!(std::fs::read_dir(out.path()).unwrap().count() <= max_written);
}
fn assert_all_rejected(entries: &[Vec<u8>], config: &SecurityConfig, max_written: usize) {
let dir = TempDir::new().unwrap();
let archive = write_archive(&dir, "root.tar", &tar_of(entries));
assert_rejected_everywhere(&archive, config, max_written);
}
#[test]
fn root_regular_file_is_rejected_everywhere() {
for name in FILE_ROOT_NAMES {
for data in [&b"hello"[..], b""] {
assert_all_rejected(
&[entry(name, tar::EntryType::Regular, data, None)],
&SecurityConfig::default(),
0,
);
}
}
}
#[test]
fn root_regular_file_in_tar_gz_is_rejected_everywhere() {
let bytes = gzip(&tar_of(&[entry(
".",
tar::EntryType::Regular,
b"hello",
None,
)]));
let dir = TempDir::new().unwrap();
let archive = write_archive(&dir, "root.tar.gz", &bytes);
assert_rejected_everywhere(&archive, &SecurityConfig::default(), 0);
}
#[test]
fn root_symlink_is_rejected_whether_or_not_symlinks_are_allowed() {
for name in ROOT_NAMES {
let entries = [entry(name, tar::EntryType::Symlink, b"", Some("sub"))];
assert_all_rejected(&entries, &SecurityConfig::default(), 0);
assert_all_rejected(&entries, &link_config(), 0);
}
}
#[test]
fn root_hardlink_is_rejected_everywhere() {
for name in ROOT_NAMES {
assert_all_rejected(
&[
entry("a.txt", tar::EntryType::Regular, b"x", None),
entry(name, tar::EntryType::Link, b"", Some("a.txt")),
],
&link_config(),
1,
);
}
}
#[test]
fn root_special_file_is_a_security_violation_everywhere() {
for kind in [
tar::EntryType::Fifo,
tar::EntryType::Char,
tar::EntryType::Block,
] {
assert_all_rejected(
&[entry(".", kind, b"", None)],
&SecurityConfig::default(),
0,
);
}
}
#[test]
fn root_file_is_rejected_when_an_extension_filter_would_skip_it() {
let dir = TempDir::new().unwrap();
let archive = write_archive(
&dir,
"root.tar",
&tar_of(&[entry(".", tar::EntryType::Regular, b"hello", None)]),
);
let out = TempDir::new().unwrap();
let config = SecurityConfig::default().with_allowed_extensions(vec!["txt".into()]);
let error = extract_archive(&archive, out.path(), &config).unwrap_err();
assert_eq!(error.error_code(), "SECURITY_VIOLATION", "{error}");
}
#[test]
fn root_symlink_with_overwrite_is_rejected_and_leaves_the_destination_alone() {
let dir = TempDir::new().unwrap();
let archive = write_archive(
&dir,
"root.tar",
&tar_of(&[entry(".", tar::EntryType::Symlink, b"", Some("sub"))]),
);
let out = TempDir::new().unwrap();
let options = ExtractionOptions::default().with_skip_duplicates(false);
std::fs::write(out.path().join("keep"), b"k").unwrap();
let error =
extract_archive_with_options(&archive, out.path(), &link_config(), &options).unwrap_err();
assert_eq!(error.error_code(), "SECURITY_VIOLATION", "{error}");
assert_eq!(std::fs::read(out.path().join("keep")).unwrap(), b"k");
}
#[test]
fn root_directory_entry_stays_a_no_op() {
for name in ROOT_NAMES {
let bytes = tar_of(&[
entry(name, tar::EntryType::Directory, b"", None),
entry("./a.txt", tar::EntryType::Regular, b"hi", None),
]);
let dir = TempDir::new().unwrap();
let archive = write_archive(&dir, "root.tar", &bytes);
let config = SecurityConfig::default();
let out = TempDir::new().unwrap();
let report = extract_archive(&archive, out.path(), &config).unwrap();
assert_eq!(report.files_skipped, 0, "{name:?}");
assert_eq!(std::fs::read(out.path().join("a.txt")).unwrap(), b"hi");
assert!(verify_archive(&archive, &config).unwrap().is_safe());
let manifest = list_archive(&archive, &config).unwrap();
assert_eq!(manifest.entries()[0].path, Path::new("."));
assert_eq!(manifest.entries()[0].kind, ManifestEntryKind::Directory);
}
}
#[test]
fn trailing_slash_regular_entry_with_payload_is_refused_everywhere() {
let bytes = tar_of(&[
entry("sub/", tar::EntryType::Regular, b"12345", None),
entry("sub/a.txt", tar::EntryType::Regular, b"hi", None),
]);
let dir = TempDir::new().unwrap();
let archive = write_archive(&dir, "payload.tar", &bytes);
let config = SecurityConfig::default();
let out = TempDir::new().unwrap();
let extracted = extract_archive(&archive, out.path(), &config).unwrap_err();
let listed = list_archive(&archive, &config).unwrap_err();
let verified = verify_archive(&archive, &config).unwrap_err();
assert_eq!(extracted.error_code(), "INVALID_ARCHIVE", "{extracted}");
assert_eq!(listed.error_code(), "INVALID_ARCHIVE", "{listed}");
assert_eq!(verified.error_code(), "INVALID_ARCHIVE", "{verified}");
assert!(!out.path().join("sub/a.txt").exists());
}
#[test]
fn v7_style_root_directory_is_accepted_everywhere() {
let bytes = tar_of(&[
entry("./", tar::EntryType::Regular, b"", None),
entry("./sub/", tar::EntryType::Regular, b"", None),
entry("./sub/a.txt", tar::EntryType::Regular, b"hi", None),
]);
let dir = TempDir::new().unwrap();
let archive = write_archive(&dir, "v7.tar", &bytes);
let config = SecurityConfig::default();
let out = TempDir::new().unwrap();
extract_archive(&archive, out.path(), &config).unwrap();
assert_eq!(std::fs::read(out.path().join("sub/a.txt")).unwrap(), b"hi");
assert!(verify_archive(&archive, &config).unwrap().is_safe());
let manifest = list_archive(&archive, &config).unwrap();
assert_eq!(manifest.entries()[0].kind, ManifestEntryKind::Directory);
assert_eq!(manifest.entries()[1].kind, ManifestEntryKind::Directory);
}