use std::{cell::RefCell, io, path::PathBuf, rc::Rc};
#[cfg(unix)]
use archive_trait::builder::SymlinkPolicy;
use archive_trait::{
ArchiveBuilder, BuildError, EntryMetadata, TraversalError,
builder::{BuildFailure, BuilderPolicy, EntryPayload},
default_name_validator,
};
use tempfile::tempdir;
use thiserror::Error;
const LARGE_FILE_BYTES: usize = 2 * 1024 * 1024 + 17;
const BATCHED_FILE_BYTES: usize = 512 * 1024 + 17;
#[derive(Debug, Eq, PartialEq)]
enum RecordedEntry {
File {
path: String,
data: Vec<u8>,
executable: bool,
chunks: usize,
},
Directory(String),
SymbolicLink {
path: String,
target: String,
},
}
impl RecordedEntry {
fn file(path: &str, data: &[u8], executable: bool) -> Self {
Self::File {
path: path.to_owned(),
data: data.to_vec(),
executable,
chunks: 1,
}
}
}
#[derive(Debug, Error)]
#[error("injected format failure")]
struct MockError;
struct MockFormat {
entries: Rc<RefCell<Vec<RecordedEntry>>>,
recoverable_failure: Option<String>,
poisoned_failure: Option<String>,
truncate_source: Option<PathBuf>,
}
impl MockFormat {
fn new() -> Self {
Self {
entries: Rc::new(RefCell::new(Vec::new())),
recoverable_failure: None,
poisoned_failure: None,
truncate_source: None,
}
}
fn entries(&self) -> Rc<RefCell<Vec<RecordedEntry>>> {
Rc::clone(&self.entries)
}
fn truncate_source(&mut self) {
let Some(path) = self.truncate_source.take() else {
return;
};
let result = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.map(drop);
assert!(result.is_ok(), "source mutation should succeed: {result:?}");
}
}
impl ArchiveBuilder for MockFormat {
type Error = MockError;
async fn finish_archive(&mut self) -> Result<(), BuildFailure<Self::Error>> {
Ok(())
}
async fn write_file_member(
&mut self,
path: &str,
payload: &mut EntryPayload<'_>,
metadata: EntryMetadata,
) -> Result<(), BuildFailure<Self::Error>> {
if self.recoverable_failure.as_deref() == Some(path) {
return Err(BuildFailure::recoverable(BuildError::Encoder(MockError)));
}
if self.poisoned_failure.as_deref() == Some(path) {
return Err(BuildFailure::poisoned(BuildError::Encoder(MockError)));
}
self.truncate_source();
let mut data = Vec::new();
let mut chunks = 0;
loop {
match payload.next_chunk().await {
Ok(Some(chunk)) => {
chunks += 1;
data.extend_from_slice(chunk);
}
Ok(None) => break,
Err(error) => return Err(BuildFailure::poisoned(error)),
}
}
self.entries.borrow_mut().push(RecordedEntry::File {
path: path.to_owned(),
data,
executable: metadata.is_executable(),
chunks,
});
Ok(())
}
async fn write_directory_member(
&mut self,
path: &str,
) -> Result<(), BuildFailure<Self::Error>> {
if self.recoverable_failure.as_deref() == Some(path) {
return Err(BuildFailure::recoverable(BuildError::Encoder(MockError)));
}
if self.poisoned_failure.as_deref() == Some(path) {
return Err(BuildFailure::poisoned(BuildError::Encoder(MockError)));
}
self.entries
.borrow_mut()
.push(RecordedEntry::Directory(path.to_owned()));
Ok(())
}
async fn write_symbolic_link_member(
&mut self,
path: &str,
target: &str,
) -> Result<(), BuildFailure<Self::Error>> {
self.entries.borrow_mut().push(RecordedEntry::SymbolicLink {
path: path.to_owned(),
target: target.to_owned(),
});
Ok(())
}
}
#[tokio::test]
async fn manual_entries_preserve_order_metadata_and_collision_state() {
let format = MockFormat::new();
let entries = format.entries();
let mut builder = format.builder();
builder
.add_entry(
"bin/tool",
b"run",
EntryMetadata::default().executable(true),
)
.await
.expect("first entry should be added");
builder
.add_entry("README", b"hello", EntryMetadata::default())
.await
.expect("second entry should be added");
for path in ["bin/tool", "bin/tool/child"] {
assert!(matches!(
builder.add_entry(path, b"", EntryMetadata::default()).await,
Err(BuildError::PathCollision { .. })
));
}
let temp = tempdir().expect("temporary directory should be created");
let directory = temp.path().join("bin");
std::fs::create_dir(&directory).expect("directory should be created");
builder
.add_directory(&directory)
.await
.expect("an implicit ancestor should accept its explicit directory member");
builder
.add_entry("bin/other", b"other", EntryMetadata::default())
.await
.expect("preflight failures should leave the builder usable");
assert_eq!(
entries.borrow().as_slice(),
[
RecordedEntry::file("bin/tool", b"run", true),
RecordedEntry::file("README", b"hello", false),
RecordedEntry::Directory("bin".to_owned()),
RecordedEntry::file("bin/other", b"other", false),
]
);
}
#[tokio::test]
async fn name_validation_supports_default_custom_and_disabled_policies() {
let policies = [
(
BuilderPolicy::default(),
" leading",
false,
"default validation should reject boundary whitespace",
),
(
BuilderPolicy::default().name_validator(Some(|name| {
default_name_validator(name) && !name.contains("blocked")
})),
"blocked",
false,
"custom validation should reject matching names",
),
(
BuilderPolicy::default().name_validator(None),
" leading",
true,
"disabled validation should accept boundary whitespace",
),
];
for (policy, path, accepted, context) in policies {
let mut builder = MockFormat::new().builder_with_policy(policy);
let result = builder.add_entry(path, b"", EntryMetadata::default()).await;
assert_eq!(result.is_ok(), accepted, "{context}: {result:?}");
if !accepted {
builder
.add_entry("accepted", b"ok", EntryMetadata::default())
.await
.expect("name rejection should leave the builder usable");
}
}
}
#[tokio::test]
async fn recursive_build_sorts_entries_batches_small_files_and_streams_large_files() {
let temp = tempdir().expect("temporary directory should be created");
let source = temp.path().join("tree");
std::fs::create_dir_all(source.join("sub")).expect("source tree should be created");
std::fs::create_dir(source.join("batch")).expect("batch directory should be created");
for (name, byte) in [("a", b'a'), ("b", b'b'), ("c", b'c')] {
std::fs::write(
source.join("batch").join(name),
vec![byte; BATCHED_FILE_BYTES],
)
.expect("buffered source file should be written");
}
std::fs::write(source.join("z"), b"last").expect("z should be written");
std::fs::write(source.join("a"), b"first").expect("a should be written");
std::fs::write(source.join("sub/file"), b"nested").expect("small file should be written");
std::fs::write(source.join("sub/large"), vec![b'x'; LARGE_FILE_BYTES])
.expect("large file should be written");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(source.join("a"), std::fs::Permissions::from_mode(0o755))
.expect("executable permissions should be set");
}
let format = MockFormat::new();
let entries = format.entries();
let mut builder = format.builder();
builder
.add_directory(&source)
.await
.expect("directory should be added");
let entries = entries.borrow();
let paths = entries
.iter()
.map(|entry| match entry {
RecordedEntry::File { path, .. }
| RecordedEntry::Directory(path)
| RecordedEntry::SymbolicLink { path, .. } => path.as_str(),
})
.collect::<Vec<_>>();
assert_eq!(
paths,
[
"tree",
"tree/a",
"tree/batch",
"tree/batch/a",
"tree/batch/b",
"tree/batch/c",
"tree/sub",
"tree/sub/file",
"tree/sub/large",
"tree/z",
]
);
assert!(entries.iter().any(|entry| matches!(
entry,
RecordedEntry::File { path, data, chunks, .. }
if path == "tree/sub/file" && data == b"nested" && *chunks == 1
)));
assert!(entries.iter().any(|entry| matches!(
entry,
RecordedEntry::File { path, data, chunks, .. }
if path == "tree/sub/large" && data.len() == LARGE_FILE_BYTES && *chunks == 2
)));
assert!(entries.iter().any(|entry| matches!(
entry,
RecordedEntry::File { path, data, chunks, .. }
if path == "tree/z" && data == b"last" && *chunks == 1
)));
for (name, byte) in [("a", b'a'), ("b", b'b'), ("c", b'c')] {
let path = format!("tree/batch/{name}");
assert!(entries.iter().any(|entry| matches!(
entry,
RecordedEntry::File {
path: actual_path,
data,
chunks: 1,
..
} if actual_path == &path
&& data.len() == BATCHED_FILE_BYTES
&& data.iter().all(|actual| *actual == byte)
)));
}
#[cfg(unix)]
assert!(entries.iter().any(|entry| matches!(
entry,
RecordedEntry::File { path, executable: true, .. } if path == "tree/a"
)));
}
#[cfg(unix)]
#[tokio::test]
async fn recursive_build_applies_symlink_policy() {
use std::os::unix::fs::symlink;
let temp = tempdir().expect("temporary directory should be created");
let source = temp.path().join("links");
std::fs::create_dir(&source).expect("source directory should be created");
std::fs::write(source.join("target"), b"contents").expect("target should be written");
symlink("target", source.join("link")).expect("symbolic link should be created");
let directory_target = temp.path().join("directory-target");
std::fs::create_dir(&directory_target).expect("directory target should be created");
symlink("../directory-target", source.join("directory"))
.expect("directory symbolic link should be created");
let linked_root = temp.path().join("root-link");
symlink("links", &linked_root).expect("root symbolic link should be created");
for rejected_source in [&source, &linked_root] {
assert!(matches!(
MockFormat::new()
.builder()
.add_directory(rejected_source)
.await,
Err(BuildError::Traversal(
TraversalError::SymbolicLinkRejected { .. }
))
));
}
let preserve = BuilderPolicy::default().symlink_policy(SymlinkPolicy::Preserve);
let format = MockFormat::new();
let entries = format.entries();
let mut builder = format.builder_with_policy(preserve);
builder
.add_directory(&source)
.await
.expect("directory should be added");
assert!(entries.borrow().contains(&RecordedEntry::SymbolicLink {
path: "links/link".to_owned(),
target: "target".to_owned(),
}));
assert!(entries.borrow().contains(&RecordedEntry::SymbolicLink {
path: "links/directory".to_owned(),
target: "../directory-target".to_owned(),
}));
symlink("blocked", source.join("custom")).expect("custom link should be created");
let policy = preserve.name_validator(Some(|name| {
default_name_validator(name) && !name.contains("blocked")
}));
assert!(matches!(
MockFormat::new()
.builder_with_policy(policy)
.add_directory(&source)
.await,
Err(BuildError::Traversal(TraversalError::NameRejected {
context: "symbolic-link target",
value,
})) if value == "blocked"
));
std::fs::remove_file(source.join("custom")).expect("custom link should be removed");
symlink(" leading", source.join("disabled")).expect("disabled-policy link should be created");
MockFormat::new()
.builder_with_policy(preserve.name_validator(None))
.add_directory(&source)
.await
.expect("disabled validation should accept the link target");
}
#[cfg(unix)]
#[tokio::test]
async fn recursive_build_reports_non_utf8_and_unsupported_sources() {
use std::{
ffi::OsString,
os::unix::{ffi::OsStringExt as _, fs::symlink, net::UnixListener},
};
let temp = tempdir().expect("temporary directory should be created");
let source = temp.path().join("file");
std::fs::write(&source, b"contents").expect("source file should be written");
assert!(matches!(
MockFormat::new().builder().add_directory(&source).await,
Err(BuildError::Traversal(
TraversalError::SourceNotDirectory { .. }
))
));
let source = temp.path().join(" rejected");
std::fs::create_dir(&source).expect("rejected source directory should be created");
assert!(matches!(
MockFormat::new().builder().add_directory(&source).await,
Err(BuildError::Traversal(TraversalError::NameRejected {
context: "member path",
..
}))
));
let source = temp.path().join("invalid");
std::fs::create_dir(&source).expect("source directory should be created");
let invalid_name = OsString::from_vec(vec![0xff]);
if std::fs::write(source.join(&invalid_name), b"contents").is_ok() {
assert!(matches!(
MockFormat::new().builder().add_directory(&source).await,
Err(BuildError::Traversal(
TraversalError::NonUtf8SourcePath { .. }
))
));
}
let source = temp.path().join("invalid-link");
std::fs::create_dir(&source).expect("link source directory should be created");
symlink(
PathBuf::from(OsString::from_vec(vec![0xff])),
source.join("link"),
)
.expect("non-UTF-8 symbolic link should be created");
assert!(matches!(
MockFormat::new()
.builder_with_policy(BuilderPolicy::default().symlink_policy(SymlinkPolicy::Preserve),)
.add_directory(&source)
.await,
Err(BuildError::Traversal(
TraversalError::NonUtf8LinkTarget { .. }
))
));
let source = temp.path().join("socket");
std::fs::create_dir(&source).expect("socket directory should be created");
let _listener =
UnixListener::bind(source.join("listener")).expect("Unix-domain socket should be created");
assert!(matches!(
MockFormat::new().builder().add_directory(&source).await,
Err(BuildError::Traversal(
TraversalError::UnsupportedFilesystemType { .. }
))
));
}
#[cfg(unix)]
#[tokio::test]
async fn late_traversal_failures_poison_after_a_completed_batch() {
use std::os::unix::net::UnixListener;
let temp = tempdir().expect("temporary directory should be created");
let source = temp.path().join("late");
std::fs::create_dir(&source).expect("source directory should be created");
for index in 0..256 {
std::fs::create_dir(source.join(format!("entry-{index:03}")))
.expect("batch directory should be created");
}
let _listener = UnixListener::bind(source.join("unsupported"))
.expect("Unix-domain socket should be created");
let format = MockFormat::new();
let entries = format.entries();
let mut builder = format.builder();
assert!(matches!(
builder.add_directory(&source).await,
Err(BuildError::Traversal(
TraversalError::UnsupportedFilesystemType { .. }
))
));
assert!(!entries.borrow().is_empty());
assert!(matches!(
builder
.add_entry("other", b"", EntryMetadata::default())
.await,
Err(BuildError::Poisoned)
));
}
#[tokio::test]
async fn source_truncation_poison_after_member_framing() {
let temp = tempdir().expect("temporary directory should be created");
let source = temp.path().join("tree");
std::fs::create_dir(&source).expect("source directory should be created");
let file = source.join("large");
std::fs::write(&file, vec![b'x'; LARGE_FILE_BYTES]).expect("large file should be written");
let mut format = MockFormat::new();
format.truncate_source = Some(file);
let mut builder = format.builder();
assert!(matches!(
builder.add_directory(&source).await,
Err(BuildError::Filesystem { source, .. })
if source.kind() == io::ErrorKind::UnexpectedEof
));
assert!(matches!(
builder
.add_entry("other", b"", EntryMetadata::default())
.await,
Err(BuildError::Poisoned)
));
}
#[tokio::test]
async fn early_failures_leave_builders_usable_but_late_failures_poison_them() {
let temp = tempdir().expect("temporary directory should be created");
let mut builder = MockFormat::new().builder();
assert!(matches!(
builder.add_directory(temp.path().join("missing")).await,
Err(BuildError::Traversal(TraversalError::Filesystem { .. }))
));
builder
.add_entry("kept", b"ok", EntryMetadata::default())
.await
.expect("early traversal failure should leave the builder usable");
let source = temp.path().join("tree");
std::fs::create_dir(&source).expect("source directory should be created");
std::fs::write(source.join("file"), b"new").expect("source file should be written");
let mut builder = MockFormat::new().builder();
builder
.add_entry("tree/file", b"existing", EntryMetadata::default())
.await
.expect("manual entry should be added");
assert!(matches!(
builder.add_directory(&source).await,
Err(BuildError::PathCollision { .. })
));
assert!(matches!(
builder
.add_entry("other", b"", EntryMetadata::default())
.await,
Err(BuildError::Poisoned)
));
let mut format = MockFormat::new();
format.recoverable_failure = Some("failed".to_owned());
let mut builder = format.builder();
assert!(matches!(
builder
.add_entry("failed", b"payload", EntryMetadata::default())
.await,
Err(BuildError::Encoder(MockError))
));
builder
.add_entry("other", b"", EntryMetadata::default())
.await
.expect("recoverable format failure should leave the builder usable");
let mut format = MockFormat::new();
format.poisoned_failure = Some("failed".to_owned());
let mut builder = format.builder();
assert!(matches!(
builder
.add_entry("failed", b"payload", EntryMetadata::default())
.await,
Err(BuildError::Encoder(MockError))
));
assert!(matches!(
builder
.add_entry("other", b"", EntryMetadata::default())
.await,
Err(BuildError::Poisoned)
));
}