pub mod support;
use std::path::Path;
#[cfg(unix)]
use support::EntryKind;
use support::{
ArchiveBuilder, ArchiveFormat, header, pax_record, set_ustar_path, single_pax_member,
};
#[cfg(unix)]
use tar_codec::extract::LinkPolicy;
use tar_codec::{
Archive as _, DecodeError, ExtractError, ExtractPolicyViolation, TarArchive,
extract::ExtractPolicy,
};
use tar_framing::{FrameError, FrameErrorInner, PaxKeyword, UstarKind};
use tempfile::tempdir;
#[tokio::test]
async fn extracts_files_directories_large_payloads_and_archive_path_syntax() {
const LARGE_PAYLOAD_BYTES: usize = 1024 * 1024 + 7;
let temp = tempdir().unwrap();
let destination = temp.path().join("out");
let large_payload = (0..LARGE_PAYLOAD_BYTES)
.map(|index| u8::try_from(index % 251).unwrap())
.collect::<Vec<_>>();
let mut archive = ArchiveBuilder::new();
archive
.ustar("bin/tool", b'0', b"run", "", 0o755)
.ustar("bin", b'5', b"", "", 0o755)
.ustar("empty/", b'5', b"", "", 0o755)
.ustar(".", b'5', b"", "", 0o755)
.ustar("large", b'0', &large_payload, "", 0o644);
let bytes = archive.finish();
std::fs::create_dir_all(destination.join("large")).unwrap();
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await
.unwrap();
assert_eq!(std::fs::read(destination.join("bin/tool")).unwrap(), b"run");
assert!(destination.join("empty").is_dir());
assert_eq!(
std::fs::read(destination.join("large")).unwrap(),
large_payload
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_ne!(
std::fs::metadata(destination.join("bin/tool"))
.unwrap()
.permissions()
.mode()
& 0o111,
0
);
}
}
#[tokio::test]
async fn default_name_policy_rejects_colons_in_regular_file_paths() {
let bytes = single_pax_member("file:stream", b'0', b"contents", "", 0o644);
let temp = tempdir().expect("temporary directory should be created");
let destination = temp.path().join("out");
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::PolicyViolation {
position: 0,
violation: ExtractPolicyViolation::NameRejected {
context: "member path",
value,
},
}) if value == "file:stream"
));
assert!(destination.is_dir());
assert!(
std::fs::read_dir(destination)
.expect("destination should be readable")
.next()
.is_none()
);
}
#[tokio::test]
async fn rejects_directory_payload_without_writing_embedded_members() {
let embedded_header = header(ArchiveFormat::Pax, "embedded.txt", b'0', 5, "", 0o644);
let mut archive = ArchiveBuilder::new();
archive.ustar("dir/", b'5', &embedded_header, "", 0o755);
let bytes = archive.finish();
let temp = tempdir().unwrap();
let destination = temp.path().join("out");
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::Archive(DecodeError::Framing(FrameError {
position: 0,
inner: FrameErrorInner::InvalidMemberSize {
kind: UstarKind::Directory,
size: 512,
},
})))
));
assert!(destination.is_dir());
assert!(std::fs::read_dir(destination).unwrap().next().is_none());
}
#[tokio::test]
async fn rejects_directory_required_suffix_on_regular_file_without_writing_members() {
for path in [
"file.txt/",
"file.txt/.",
"file.txt//.",
"file.txt/././.",
"file.txt/./././",
"foo/bar/..",
"foo/bar/../",
] {
let mut archive = ArchiveBuilder::new();
archive
.pax(b'x', &pax_record(PaxKeyword::Path, path))
.ustar("ignored", b'0', b"hello", "", 0o644);
let bytes = archive.finish();
let temp = tempdir().expect("temporary directory should be created");
let destination = temp.path().join("out");
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::UnsafePath {
position: 1024,
context: "member path",
value,
reason: "only a directory may have a directory-required path suffix",
}) if value == path
));
assert!(destination.is_dir());
assert!(
std::fs::read_dir(destination)
.expect("destination should be readable")
.next()
.is_none()
);
}
}
#[tokio::test]
async fn accepts_directory_required_suffix_on_directory_members() {
for path in [
"directory/.",
"directory//.",
"directory/././.",
"directory/./././",
] {
let mut archive = ArchiveBuilder::new();
archive
.pax(b'x', &pax_record(PaxKeyword::Path, path))
.ustar("ignored", b'5', b"", "", 0o755);
let bytes = archive.finish();
let temp = tempdir().expect("temporary directory should be created");
let destination = temp.path().join("out");
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await
.expect("directory member should be extracted");
assert!(destination.join("directory").is_dir());
}
}
#[tokio::test]
async fn prefix_only_ustar_paths_are_directory_required() {
let temp = tempdir().expect("temporary directory should be created");
let mut directory_header = header(ArchiveFormat::Pax, "ignored", b'5', 0, "", 0o755);
set_ustar_path(&mut directory_header, "victim", "");
let mut directory_archive = ArchiveBuilder::new();
directory_archive.block(&directory_header);
let directory_bytes = directory_archive.finish();
let directory_destination = temp.path().join("directory");
TarArchive::new(directory_bytes.as_slice())
.extract_in(&directory_destination, ExtractPolicy::default())
.await
.expect("prefix-only directory member should be extracted");
assert!(directory_destination.join("victim").is_dir());
let mut file_header = header(ArchiveFormat::Pax, "ignored", b'0', 0, "", 0o644);
set_ustar_path(&mut file_header, "victim", "");
let mut file_archive = ArchiveBuilder::new();
file_archive.block(&file_header);
let file_bytes = file_archive.finish();
let file_destination = temp.path().join("file");
std::fs::create_dir(&file_destination).expect("destination should be created");
std::fs::write(file_destination.join("victim"), b"keep")
.expect("existing file should be written");
assert!(matches!(
TarArchive::new(file_bytes.as_slice())
.extract_in(&file_destination, ExtractPolicy::default())
.await,
Err(ExtractError::UnsafePath {
position: 0,
context: "member path",
value,
reason: "only a directory may have a directory-required path suffix",
}) if value == "victim/"
));
assert_eq!(
std::fs::read(file_destination.join("victim"))
.expect("existing file should remain readable"),
b"keep"
);
}
#[tokio::test]
async fn later_entries_replace_duplicate_normalized_and_ambient_files() {
let temp = tempdir().unwrap();
let destination = temp.path().join("out");
std::fs::create_dir(&destination).unwrap();
std::fs::write(destination.join("ambient"), b"ambient").unwrap();
let mut archive = ArchiveBuilder::new();
archive
.ustar("same", b'0', b"old", "", 0o644)
.ustar("same", b'0', b"new", "", 0o644)
.ustar("nested//./normalized", b'0', b"old", "", 0o644)
.ustar("nested/normalized", b'0', b"new", "", 0o644)
.ustar("ambient", b'0', b"archive", "", 0o644);
let bytes = archive.finish();
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await
.unwrap();
assert_eq!(std::fs::read(destination.join("same")).unwrap(), b"new");
assert_eq!(
std::fs::read(destination.join("nested/normalized")).unwrap(),
b"new"
);
assert_eq!(
std::fs::read(destination.join("ambient")).unwrap(),
b"archive"
);
}
#[cfg(unix)]
#[tokio::test]
async fn ambient_file_replacement_unlinks_the_inode_and_applies_mode() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let temp = tempdir().unwrap();
let destination = temp.path().join("out");
std::fs::create_dir(&destination).unwrap();
std::fs::write(destination.join("same"), b"ambient").unwrap();
std::fs::hard_link(destination.join("same"), destination.join("sibling")).unwrap();
let bytes = single_pax_member("same", b'0', b"archive", "", 0o755);
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await
.unwrap();
assert_eq!(std::fs::read(destination.join("same")).unwrap(), b"archive");
assert_eq!(
std::fs::read(destination.join("sibling")).unwrap(),
b"ambient"
);
let replaced = std::fs::metadata(destination.join("same")).unwrap();
let sibling = std::fs::metadata(destination.join("sibling")).unwrap();
assert_ne!(replaced.ino(), sibling.ino());
assert_ne!(replaced.permissions().mode() & 0o111, 0);
}
#[cfg(unix)]
#[tokio::test]
async fn later_entries_replace_representative_cross_kind_paths() {
let temp = tempdir().unwrap();
for (case, first, last) in [
("file-to-directory", EntryKind::File, EntryKind::Directory),
("directory-to-file", EntryKind::Directory, EntryKind::File),
(
"file-to-symbolic-link",
EntryKind::File,
EntryKind::SymbolicLink,
),
(
"symbolic-link-to-hard-link",
EntryKind::SymbolicLink,
EntryKind::HardLink,
),
] {
let destination = temp.path().join(case);
let mut archive = ArchiveBuilder::new();
archive
.ustar("target", b'0', b"target", "", 0o644)
.entry("./same", first, b"first")
.entry("same", last, b"last");
let bytes = archive.finish();
TarArchive::new(bytes.as_slice())
.extract_in(
&destination,
ExtractPolicy::default().link_policy(LinkPolicy::default().allow_hard_links(true)),
)
.await
.unwrap();
match last {
EntryKind::File => {
assert_eq!(
std::fs::read(destination.join("same")).unwrap(),
b"last",
"{case}"
);
}
EntryKind::Directory => assert!(destination.join("same").is_dir(), "{case}"),
EntryKind::SymbolicLink => {
assert_eq!(
std::fs::read_link(destination.join("same")).unwrap(),
Path::new("target"),
"{case}"
);
}
EntryKind::HardLink => {
std::fs::write(destination.join("target"), b"updated").unwrap();
assert_eq!(
std::fs::read(destination.join("same")).unwrap(),
b"updated",
"{case}"
);
}
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn extraction_replaces_empty_leaves_but_rejects_non_directory_parents() {
let temp = tempdir().unwrap();
for (case, existing_file, archive_kind) in [
("file-to-directory", true, EntryKind::Directory),
("file-to-symbolic-link", true, EntryKind::SymbolicLink),
("directory-to-file", false, EntryKind::File),
("directory-to-hard-link", false, EntryKind::HardLink),
] {
let destination = temp.path().join(case);
std::fs::create_dir(&destination).unwrap();
if existing_file {
std::fs::write(destination.join("same"), b"ambient").unwrap();
} else {
std::fs::create_dir(destination.join("same")).unwrap();
}
let mut archive = ArchiveBuilder::new();
archive
.ustar("target", b'0', b"target", "", 0o644)
.entry("same", archive_kind, b"archive");
let bytes = archive.finish();
TarArchive::new(bytes.as_slice())
.extract_in(
&destination,
ExtractPolicy::default().link_policy(LinkPolicy::default().allow_hard_links(true)),
)
.await
.unwrap();
match archive_kind {
EntryKind::File => {
assert_eq!(std::fs::read(destination.join("same")).unwrap(), b"archive");
}
EntryKind::Directory => assert!(destination.join("same").is_dir()),
EntryKind::SymbolicLink => {
assert_eq!(
std::fs::read_link(destination.join("same")).unwrap(),
Path::new("target")
);
}
EntryKind::HardLink => {
std::fs::write(destination.join("target"), b"updated").unwrap();
assert_eq!(std::fs::read(destination.join("same")).unwrap(), b"updated");
}
}
}
for (case, parent) in [
("file-parent", EntryKind::File),
("symbolic-link-parent", EntryKind::SymbolicLink),
("hard-link-parent", EntryKind::HardLink),
] {
let destination = temp.path().join(case);
let mut archive = ArchiveBuilder::new();
archive
.ustar("target", b'0', b"target", "", 0o644)
.entry("parent", parent, b"old")
.pax(b'x', &pax_record(PaxKeyword::Path, "parent/child"))
.ustar("ignored", b'0', b"new", "", 0o644);
let bytes = archive.finish();
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(
&destination,
ExtractPolicy::default().link_policy(LinkPolicy::default().allow_hard_links(true)),
)
.await,
Err(ExtractError::PathCollision { path }) if path == Path::new("parent")
));
assert!(!destination.join("parent/child").exists());
match parent {
EntryKind::File => {
assert_eq!(std::fs::read(destination.join("parent")).unwrap(), b"old");
}
EntryKind::HardLink => {
assert_eq!(
std::fs::read(destination.join("parent")).unwrap(),
b"target"
);
}
EntryKind::SymbolicLink => {
assert!(!destination.join("parent").exists());
}
EntryKind::Directory => {}
}
}
let destination = temp.path().join("ambient-parent");
std::fs::create_dir(&destination).unwrap();
std::fs::write(destination.join("parent"), b"old").unwrap();
let bytes = single_pax_member("parent/child", b'0', b"new", "", 0o644);
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::PathCollision { path }) if path == Path::new("parent")
));
assert_eq!(std::fs::read(destination.join("parent")).unwrap(), b"old");
}
#[cfg(unix)]
#[tokio::test]
async fn disabled_overwrites_reject_replacements_but_reuse_directories() {
let temp = tempdir().unwrap();
let archives = [
("duplicate", false, {
let mut archive = ArchiveBuilder::new();
archive
.ustar("same", b'0', b"old", "", 0o644)
.ustar("same", b'0', b"new", "", 0o644);
archive.finish()
}),
("cross-kind", false, {
let mut archive = ArchiveBuilder::new();
archive
.ustar("same", b'0', b"old", "", 0o644)
.ustar("same", b'5', b"", "", 0o755);
archive.finish()
}),
("parent", false, {
let mut archive = ArchiveBuilder::new();
archive.ustar("parent", b'0', b"old", "", 0o644).ustar(
"parent/child",
b'0',
b"new",
"",
0o644,
);
archive.finish()
}),
("pending-symlink", false, {
let mut archive = ArchiveBuilder::new();
archive
.ustar("same", b'2', b"", "missing", 0o644)
.ustar("same", b'0', b"new", "", 0o644);
archive.finish()
}),
(
"ambient",
true,
single_pax_member("same", b'0', b"new", "", 0o644),
),
];
for (case, preexisting_file, bytes) in archives {
let destination = temp.path().join(case);
if preexisting_file {
std::fs::create_dir(&destination).unwrap();
std::fs::write(destination.join("same"), b"ambient").unwrap();
}
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(
&destination,
ExtractPolicy::default().allow_overwrites(false),
)
.await,
Err(ExtractError::PathCollision { .. })
));
}
let destination = temp.path().join("directories");
std::fs::create_dir_all(destination.join("same")).unwrap();
let mut archive = ArchiveBuilder::new();
archive
.ustar("same/child", b'0', b"new", "", 0o644)
.ustar("same", b'5', b"", "", 0o755)
.ustar("same", b'5', b"", "", 0o755);
let bytes = archive.finish();
TarArchive::new(bytes.as_slice())
.extract_in(
&destination,
ExtractPolicy::default().allow_overwrites(false),
)
.await
.unwrap();
assert_eq!(
std::fs::read(destination.join("same/child")).unwrap(),
b"new"
);
}
#[cfg(unix)]
#[tokio::test]
async fn non_empty_directories_are_never_replaced() {
let temp = tempdir().unwrap();
let archives = [
("archive-child", {
let mut archive = ArchiveBuilder::new();
archive
.ustar("same/child", b'0', b"keep", "", 0o644)
.ustar("same", b'0', b"replace", "", 0o644);
archive.finish()
}),
("pending-symlink-child", {
let mut archive = ArchiveBuilder::new();
archive
.ustar("same/link", b'2', b"", "missing", 0o644)
.ustar("same", b'0', b"replace", "", 0o644);
archive.finish()
}),
];
for (case, bytes) in archives {
let destination = temp.path().join(case);
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::PathCollision { .. })
));
assert!(destination.join("same").is_dir());
}
let destination = temp.path().join("ambient-child");
std::fs::create_dir_all(destination.join("same")).unwrap();
std::fs::write(destination.join("same/child"), b"keep").unwrap();
let bytes = single_pax_member("same", b'0', b"replace", "", 0o644);
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::PathCollision { .. })
));
assert!(destination.join("same").is_dir());
}
#[cfg(any(unix, windows))]
#[tokio::test]
async fn extraction_rejects_symlink_parents_and_replaces_symlink_leaves_without_following() {
use support::{symlink_dir, symlink_file};
let temp = tempdir().unwrap();
let destination = temp.path().join("parents");
let outside = temp.path().join("outside-directory");
std::fs::create_dir_all(&destination).unwrap();
std::fs::create_dir_all(&outside).unwrap();
symlink_dir(&outside, destination.join("parent")).unwrap();
let bytes = single_pax_member("parent/file", b'0', b"good", "", 0o644);
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::PathCollision { path }) if path == Path::new("parent")
));
assert_eq!(
std::fs::read_link(destination.join("parent")).unwrap(),
outside
);
assert!(!outside.join("file").exists());
let destination = temp.path().join("leaf");
let outside = temp.path().join("outside-file");
std::fs::create_dir(&destination).unwrap();
std::fs::write(&outside, b"keep").unwrap();
symlink_file(&outside, destination.join("same")).unwrap();
let bytes = single_pax_member("same", b'0', b"archive", "", 0o644);
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await
.unwrap();
assert_eq!(std::fs::read(destination.join("same")).unwrap(), b"archive");
assert_eq!(std::fs::read(&outside).unwrap(), b"keep");
}
#[cfg(any(unix, windows))]
#[tokio::test]
async fn rejects_a_symlink_destination_root_without_modifying_its_target() {
use support::symlink_dir;
let temp = tempdir().unwrap();
let target = temp.path().join("target");
let destination = temp.path().join("link");
std::fs::create_dir(&target).unwrap();
std::fs::write(target.join("keep"), b"keep").unwrap();
symlink_dir(&target, &destination).unwrap();
let bytes = single_pax_member("file", b'0', b"archive", "", 0o644);
assert!(matches!(
TarArchive::new(bytes.as_slice())
.extract_in(&destination, ExtractPolicy::default())
.await,
Err(ExtractError::Filesystem { .. })
));
assert_eq!(std::fs::read(target.join("keep")).unwrap(), b"keep");
assert!(!target.join("file").exists());
}